Azure Table の検索条件を LINQ で指定する

Azure Storage の SDK (Windows Azure Storage) を利用して .NET のクライアントから Table のデータを取得する際に、
検索条件を指定しようとすると、通常の実装では次のようなコードになり少し複雑です。
この例では、フィルター条件を 2 つ指定しています。


var query = new TableQuery<Person>()
    .Where(TableQuery.CombineFilters(
        TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, "2015"),
        TableOperators.And,
        TableQuery.GenerateFilterConditionForInt("Age", QueryComparisons.LessThan, 20)));

var result = PeopleTable.ExecuteQuery(query).ToArray();


ここで、Person  は TableEntity を継承したクラスで、PeopleTable は CloudTable 型のオブジェクトです。

結局、上記の Where メソッドに渡されるのは、

(PartitionKey eq ‘2015’) and (Age lt 20)

という文字列になります。
これなら string.Format メソッドでもよいのではないのかという気もしますが、
プログラミングのミスを防ぐためには、フィルターや射影などの検索条件は LINQ で指定したいところです。

検索条件を LINQ で指定する方法として TableServiceContext クラスを使う方法もあるようですが、
現在は Obsolete 属性が指定されており、非推奨となっています。

とはいえ、自力で IQueryable<T> を実装するのも骨が折れるので、
ここでは簡易的に、TableQuery<T> の拡張メソッドとして Select および Where メソッドを実装していきます。
ラムダ式で指定された検索条件を式ツリーとして受け取って解析し、動的にクエリを生成します。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using Microsoft.WindowsAzure.Storage.Table;
namespace ExpressionsConsole
{
public static class TableHelper
{
static readonly MethodInfo String_CompareTo = typeof(string).GetMethod("CompareTo", new[] { typeof(string) });
public static TableQuery<TElement> Select<TElement>(this TableQuery<TElement> query, Expression<Func<TElement, object>> selector)
{
if (query == null) throw new ArgumentNullException("query");
if (selector == null) throw new ArgumentNullException("selector");
var @new = selector.Body as NewExpression;
if (@new == null) throw new InvalidOperationException();
query.SelectColumns = @new.Constructor.GetParameters().Select(p => p.Name).ToArray();
return query;
}
public static TableQuery<TElement> Where<TElement>(this TableQuery<TElement> query, Expression<Func<TElement, bool>> predicate)
{
if (query == null) throw new ArgumentNullException("query");
if (predicate == null) throw new ArgumentNullException("predicate");
var binary = predicate.Body as BinaryExpression;
if (binary == null) throw new InvalidOperationException();
var filter = GenerateFilter(binary);
query.FilterString = string.IsNullOrWhiteSpace(query.FilterString) ? filter : TableQuery.CombineFilters(query.FilterString, TableOperators.And, filter);
return query;
}
static string GenerateFilter(BinaryExpression binary)
{
switch (binary.NodeType)
{
case ExpressionType.AndAlso:
case ExpressionType.OrElse:
return CombineFilters(binary);
case ExpressionType.Equal:
case ExpressionType.NotEqual:
case ExpressionType.GreaterThan:
case ExpressionType.GreaterThanOrEqual:
case ExpressionType.LessThan:
case ExpressionType.LessThanOrEqual:
return (binary.Left is MethodCallExpression) ? GenerateFilterConditionForMethodCall(binary) : GenerateFilterCondition(binary);
default:
throw new InvalidOperationException();
}
}
static string CombineFilters(BinaryExpression binary)
{
var left = binary.Left as BinaryExpression;
if (left == null) throw new InvalidOperationException();
var right = binary.Right as BinaryExpression;
if (right == null) throw new InvalidOperationException();
var op = ToCombinationOperator(binary.NodeType);
return TableQuery.CombineFilters(GenerateFilter(left), op, GenerateFilter(right));
}
static string GenerateFilterCondition(BinaryExpression binary)
{
var left = binary.Left as MemberExpression;
if (left == null) throw new InvalidOperationException();
var op = ToComparisonOperator(binary.NodeType);
var rightValue = binary.Right.Invoke();
return
left.Type == typeof(byte[]) ? TableQuery.GenerateFilterConditionForBinary(left.Member.Name, op, (byte[])rightValue) :
left.Type == typeof(bool) ? TableQuery.GenerateFilterConditionForBool(left.Member.Name, op, (bool)rightValue) :
left.Type == typeof(DateTime) ? TableQuery.GenerateFilterConditionForDate(left.Member.Name, op, (DateTime)rightValue) :
left.Type == typeof(DateTimeOffset) ? TableQuery.GenerateFilterConditionForDate(left.Member.Name, op, (DateTimeOffset)rightValue) :
left.Type == typeof(double) ? TableQuery.GenerateFilterConditionForDouble(left.Member.Name, op, (double)rightValue) :
left.Type == typeof(Guid) ? TableQuery.GenerateFilterConditionForGuid(left.Member.Name, op, (Guid)rightValue) :
left.Type == typeof(int) ? TableQuery.GenerateFilterConditionForInt(left.Member.Name, op, (int)rightValue) :
left.Type == typeof(long) ? TableQuery.GenerateFilterConditionForLong(left.Member.Name, op, (long)rightValue) :
TableQuery.GenerateFilterCondition(left.Member.Name, op, rightValue.To<string>());
}
static string GenerateFilterConditionForMethodCall(BinaryExpression binary)
{
var methodCall = binary.Left as MethodCallExpression;
if (methodCall == null) throw new InvalidOperationException();
if (methodCall.Method != String_CompareTo) throw new InvalidOperationException();
var left = methodCall.Object as MemberExpression;
if (left == null) throw new InvalidOperationException();
var op = ToComparisonOperator(binary.NodeType);
var rightValue = methodCall.Arguments[0].Invoke();
return TableQuery.GenerateFilterCondition(left.Member.Name, op, rightValue.To<string>());
}
static string ToCombinationOperator(ExpressionType nodeType)
{
switch (nodeType)
{
case ExpressionType.AndAlso:
return TableOperators.And;
case ExpressionType.OrElse:
return TableOperators.Or;
default:
throw new InvalidOperationException();
}
}
static string ToComparisonOperator(ExpressionType nodeType)
{
switch (nodeType)
{
case ExpressionType.Equal:
case ExpressionType.NotEqual:
case ExpressionType.GreaterThan:
case ExpressionType.GreaterThanOrEqual:
case ExpressionType.LessThan:
case ExpressionType.LessThanOrEqual:
return (string)typeof(QueryComparisons).GetField(nodeType.ToString()).GetValue(null);
default:
throw new InvalidOperationException();
}
}
}
}
view raw TableHelper.cs hosted with ❤ by GitHub
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Diagnostics;
using System.Linq;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Table;
namespace ExpressionsConsole
{
public static class TableTest
{
static readonly CloudTable PeopleTable;
static TableTest()
{
var accountString = ConfigurationManager.ConnectionStrings["StorageAccount"].ConnectionString;
var account = CloudStorageAccount.Parse(accountString);
var tableClient = account.CreateCloudTableClient();
PeopleTable = tableClient.GetTableReference("people");
}
public static void SelectTest()
{
var query = new TableQuery<Person>()
.Select(p => new { p.FirstName, p.Age });
var result = PeopleTable.ExecuteQuery(query).ToArray();
}
public static void WhereTest1()
{
// (PartitionKey eq '2015') and ((LastName ge 'W') or (Age lt 20))
var query = new TableQuery<Person>()
.Where(p => p.PartitionKey == "2015" && (p.LastName.CompareTo("W") >= 0 || p.Age < 20));
var result = PeopleTable.ExecuteQuery(query).ToArray();
}
public static void WhereTest2()
{
// (PartitionKey eq '2015') and ((LastName ge 'W') or (Age lt 20))
var query = new TableQuery<Person>()
.Where(p => p.PartitionKey == "2015")
.Where(p => p.LastName.CompareTo("W") >= 0 || p.Age < 20);
var result = PeopleTable.ExecuteQuery(query).ToArray();
}
public static void WhereSelectTest()
{
var query = new TableQuery<Person>()
.Where(p => p.PartitionKey == "2015")
.Where(p => p.LastName.CompareTo("W") >= 0 || p.Age < 20)
.Select(p => new { p.FirstName, p.Age });
var result = PeopleTable.ExecuteQuery(query).ToArray();
}
}
[DebuggerDisplay(@"\{{PartitionKey}/{RowKey}\}")]
public class Person : TableEntity
{
public string FirstName { get; set; }
public string LastName { get; set; }
public int Age { get; set; }
}
}
view raw TableTest.cs hosted with ❤ by GitHub

このような TableHelper クラスを実装することで、Azure Table の検索条件を LINQ で指定できるようになります。
ただし、文字列の不等式については、String クラスの演算子として不等号が定義されていないため、

p.LastName >= "W"

と書くことができず、

p.LastName.CompareTo("W") >= 0

のようにせざるを得ませんでした。

 

作成したサンプル
ExpressionsConsole (GitHub)

バージョン情報
Windows Azure Storage 6.0.0

参照
Windows Azure Storage
Expression<TDelegate> クラス
TableQuery<TElement> Class

Windows Azure Storage Extensions
テーブル サービスに対する LINQ クエリの作成 (古い形式)
TableServiceContext Class (古い形式)

エンティティを匿名型で手軽に実装する (2)

前回のエンティティを匿名型で手軽に実装する (1) で作成した EntityType<TEntity> では、
インスタンスを初期化するために毎回 ConstructorInfo を経由していましたが、
少し重い処理のため、回数が多いと処理時間に影響します。

そこで、あらかじめコンストラクターをラムダ式の式ツリーからコンパイルしておくという方法があります。
先にコードを示します。

using System;
using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
namespace ExpressionsConsole
{
[DebuggerDisplay(@"{typeof(TEntity)}")]
public class EntityType<TEntity>
{
public ConstructorInfo ConstructorInfo { get; private set; }
Func<object[], TEntity> _constructor;
internal EntityType(ConstructorInfo constructorInfo)
{
ConstructorInfo = constructorInfo;
_constructor = CompileConstructor(constructorInfo);
}
public TEntity CreateEntity(params object[] parameters)
{
return _constructor(parameters);
}
static Func<object[], TEntity> CompileConstructor(ConstructorInfo constructorInfo)
{
var parameterInfoes = constructorInfo.GetParameters();
var p = Expression.Parameter(typeof(object[]), "p");
var ctorExp = Expression.New(constructorInfo, parameterInfoes.Select(i => GetParameterValue(p, i)));
// p => new TEntity((int)p[0], (string)p[1])
var ctorLambda = Expression.Lambda<Func<object[], TEntity>>(ctorExp, p);
return ctorLambda.Compile();
}
static Expression GetParameterValue(ParameterExpression p, ParameterInfo info)
{
var p_i = Expression.ArrayIndex(p, Expression.Constant(info.Position));
return Expression.Convert(p_i, info.ParameterType);
}
}
}
view raw EntityType.cs hosted with ❤ by GitHub
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ExpressionsConsole
{
class Program
{
static void Main(string[] args)
{
EntityTypeTimeTest();
}
static void EntityTypeTimeTest()
{
var sw = Stopwatch.StartNew();
var PersonType = EntityType.Create(new { Id = 0, Name = "", Birthday = DateTime.MinValue });
Console.WriteLine(sw.Elapsed);
for (var i = 0; i < 1000000; i++)
PersonType.CreateEntity(i, "Person", DateTime.MaxValue);
sw.Stop();
Console.WriteLine(sw.Elapsed);
}
}
}
view raw Program.cs hosted with ❤ by GitHub

取得した ConstructorInfo をもとに、

p => new TEntity((int)p[0], (string)p[1])

のような処理に相当する式ツリーを構築してコンパイルすることで、object[] から TEntity を初期化する関数が得られます。

CreateEntity メソッドの呼び出しを 100 万回実行して計測してみると、手元の環境だと前回のコードでは

  • EntityType の初期化: 0.0006 秒
  • CreateEntity メソッド 100 万回: 0.6 秒

だったのが、今回のコードでは

  • EntityType の初期化: 0.006 秒
  • CreateEntity メソッド 100 万回: 0.06 秒

になりました。

 

前回: エンティティを匿名型で手軽に実装する (1)

作成したサンプル
ExpressionsConsole (GitHub)

参照
Expression<TDelegate> クラス

メソッドの引数の名前を式ツリーから取得する

メソッドに渡された引数の値が null でないことや空文字でないことなどを検証するために、
次のようなコードを記述することと思います。


static void Method1(string parameter1)
{
    if (parameter1 == null) throw new ArgumentNullException("parameter1");
    if (parameter1.Length == 0) throw new ArgumentException("値を空にすることはできません。", "parameter1");

    // 以下省略。
}


このようなコードは頻繁に必要になるので、コード スニペットを用意しておくと便利です

メソッド チェーンにしたい場合は、次のように拡張メソッドを使います。


static class Program
{
    static string[] Separate(this string text)
    {
        return text
            .AssertArgumentNotNull("text")
            .AssertArgumentNotEmpty("text")
            .Split(‘:’)
            .Select(s => s.Trim())
            .ToArray();
    }
}

[DebuggerNonUserCode]
public static class Assert
{
    public static T AssertArgumentNotNull<T>(this T value, string name) where T : class
    {
        if (value == null) throw new ArgumentNullException(name); 
        return value;
    }

    public static string AssertArgumentNotEmpty(this string value, string name)
    {
        if (value.Length == 0) throw new ArgumentException("値を空にすることはできません。", name); 
        return value;
    }
}


上記の方法ではメソッドの引数の名前を文字列のリテラル ("parameter1" のような形式) で記述することになりますが、
どうしてもこれを避けたい場合、ラムダ式による式ツリー (式木, Expression Tree) を利用することによって実現できます。


static class Program
{
    static void Method1(string parameter1)
    {
        Assert.IsArgumentNotNull(() => parameter1);
        Assert.IsArgumentNotEmpty(() => parameter1);

        // 以下省略。
    }
}

[DebuggerNonUserCode]
public static class Assert
{
    public static void IsArgumentNotNull<T>(Expression<Func<T>> getValue) where T : class
    {
        var body = (MemberExpression)getValue.Body;
        var field = (FieldInfo)body.Member;
        var value = getValue.Compile()();

        if (value == null) throw new ArgumentNullException(field.Name);
    }

    public static void IsArgumentNotEmpty(Expression<Func<string>> getValue)
    {
        var body = (MemberExpression)getValue.Body;
        var field = (FieldInfo)body.Member;
        var value = getValue.Compile()();

        if (value.Length == 0) throw new ArgumentException("値を空にすることはできません。", field.Name);
    }
}


Expression<TDelegate>.Compile メソッドを呼び出すことで、式ツリーをデリゲートに変換しています。

バージョン情報
.NET Framework 3.5, 4

参照
式ツリー (C# および Visual Basic)