ドメインモデルをLINQで構築する(その6)

今回はお買い上げの様々なバリエーションを実装していきます。

オプション商品の指定

オプションを持つ商品をお買い上げするシナリオです。

[TestMethod]
public void TestSetOrder()
{
    using (var db = new BurgerShopDataContext())
    {
        db.Log = Console.Out;
        var shops = db.GetTable();
        var orders = db.GetTable();
        var products = db.GetTable();

        var shop = shops.Single(s => s.Name == "OSAKA");
        var myOrder = shop.CreateOrder();

        var setItem = myOrder.AddItem(products.FindSaleProduct("CheeseBurgerSet"));
        var setOption = setItem.Product.OptionList;
        setItem.Choice(setOption.FindByTitle("Main", "CheeseBurger"));
        setItem.Choice(setOption.FindByTitle("Sub", "PotatoS"));
        setItem.Choice(setOption.FindByTitle("Drink", "CoffeeM"));

        Assert.AreEqual(350m, myOrder.TotalPrice);
        orders.Add(myOrder);
        db.SubmitChanges();
    }
}


オプションのある商品をお買い上げに追加しオプションを設定します。オプションリストのヘルパーメソッドの追加して簡潔に記述できるようにします。またまた拡張メソッドを利用します。カスタムのコレクションを作成しなくてもカスタムのメソッドを提供できるのは便利です。

public static class ProductOptionExtender
{
    public static ProductOption FindByTitle(this IEnumerable list,
		 string itemName, string title)
    {
        return list.Single(o => o.ItemName == itemName && o.Title == title);
    }
}


とりあえず、コンパイルが通るところまで作成します。購入商品Productプロパティと購入した商品のオプションを選択するChoiceメソッドを空で作成します。

partial class OrderItem
{
    ...
    public SaleProduct Product
    {
        get {return _product;}
        private set
        {
            _product = value;
            this._ProductID = value.ProductID;
        }
    }
    private SaleProduct _product;

    public void Choice(ProductOption option)
    {
    }
}


Choiceメソッドの実装も永続化対象の導出項目のUnitPriceの更新処理がある程度で選択したオプションからOrderItemOptionを作成・追加は直観的な流れになっています。

partial class OrderItem
{
    ...
    public void Choice(ProductOption option)
    {
        var aOption = new OrderItemOption(this, option);
        this.UpdateUnitPrice();
        this.Order.UpdateTotalPrice();
    }

    public void UpdateUnitPrice()
    {
        this._UnitPrice = this.Product.Price 
            + this.OrderItemOptions.Sum(o => o.Option.AdditionalPrice);
    }
    ...
}
partial class OrderItemOption
{
    public ProductOption Option { get; private set; }
 
    public OrderItemOption(OrderItem item, ProductOption optionProduct)
        : this()
    {
        this.OrderItem = item;
        this.Option = optionProduct;
        this._ItemName = optionProduct.ItemName;
        this._ProductID = optionProduct.Option.ProductID;
    }
}


目標のテストケースをクリアできました。

不適切なオプションの検証

次はオプションとして不適切なものが指定された場合にエラーを発生させるシナリオを実装します。チーズバーガーのセットにハンバーガーを設定した場合にエラーになるテストケースを目標にモデルを実装していきます。

[TestMethod]
public void TestInvalidOption()
{
    ...
        var setItem = myOrder.AddItem(products.FindSaleProduct("CheeseBurgerSet"));
        var setOption = setItem.Product.OptionList;
        try
        {
            setItem.Choice(new ProductOption(
                "Main",products.FindSaleProduct("Burger"),0,true));
            Assert.Fail();
        }
        catch (BurgerShopApplicationExcpetion ex) 
        {
            Assert.AreEqual("ErrorInvalidOption", ex.MessageCode);
        }
    }
}


Choiceメソッドで指定されたオプションを検証しますが、実際には該当の商品に対して検証処理を委任して実装します。LINQを使うとコレクション処理が本当に簡潔に記述できます。SaleProductやProductItemの複数のクラスに分散実装するのがめんどくさく感じるかもしれませんが、実装するメソッドが小さくなり共通化も進むのでデメテルの法則をできるだけ守るようにします。

partial class OrderItem
{
    ...
    public void Choice(ProductOption option)
    {
        if (!this.Product.IsConsistOf(option)) AppUtil.RaiseError("ErrorInvalidOption");

        var aOption = new OrderItemOption(this, option);
        this.UpdateUnitPrice();
        this.Order.UpdateTotalPrice();
    }
    ...
}
partial class SaleProduct
{
    ...
    public bool IsConsistOf(ProductOption target)
    {
        return this.ProductItems.Any(item => item.IsConsistOf(target));
    }
}
partial class ProductItem
{
    ...
    public bool IsConsistOf(ProductOption target)
    {
        return GetOptionList().Contains(target);
    }
}

オプションの変更

お買い上げの商品のオプションを変更するシナリオです。ポテトSをチキンMに変更するシナリオを実装します。さらに、チキンを選択することによって新たにチキンのソースを指定するようにします。

[TestMethod]
public void TestChangeOption()
{
    ...
        var shop = shops.Single(s => s.Name == "OSAKA");
        var myOrder = shop.CreateOrder();

        var setItem = myOrder.AddItem(products.FindSaleProduct("CheeseBurgerSet"));
        var setOption = setItem.Product.OptionList;
        setItem.Choice(setOption.FindByTitle("Main", "CheeseBurger"));
        setItem.Choice(setOption.FindByTitle("Sub", "PotatoS"));
        setItem.Choice(setOption.FindByTitle("Drink", "CoffeeM"));
        Assert.AreEqual(350m, myOrder.TotalPrice);

        setItem.Choice(setOption.FindByTitle("Sub", "ChickenM"));
        setItem.Choice(setOption.FindByTitle("Drink", "CoffeeS"));

        var ChickenOption = products.FindSaleProduct("ChickenM").OptionList;
        setItem.Choice(ChickenOption.FindByTitle("ChickenAttached", "BarbecueSource"));

        Assert.AreEqual(450m, myOrder.TotalPrice);
    ...
}


Choice時可能なオプションとして構成商品以外に構成商品のオプションもできるようにします。チーズバーガーセットでチキンを選択した場合にはバーベキューソースを指定できるということです。
また、構成商品で同じアイテムの商品が既にオプションとして指定されている場合はクリアして新しい商品をオプションに指定します。このとき前回のオプションのオプションについてもクリアする必要があります。
現在のオプション指定の構造は、オプションを持つ商品のアイテム名(Main,Sub,Drink,Source,etc)はツリー構造の中で重複しないという制約を前提に処理を行っています。したがって、オプションのオプション(孫)がどのオプション(子)に所属しているか直接的には不明で商品の構造から推測して決定しなければなりません。このため検証処理なので若干取り扱いにくくなっています。OrderItemOptionを改訂して一時的にオプション構造を管理する方法もありますが、もう少し待つことにしました。

partial class OrderItem
{
    ...
    public void Choice(ProductOption option)
    {
        if (!this.Product.IsConsistOf(option) && !IsOptionConsistOf(option))
            AppUtil.RaiseError("ErrorInvalidOption");
        ResetOption(option.ItemName);

        var aOption = new OrderItemOption(this, option);
        this.UpdateUnitPrice();
        this.Order.UpdateTotalPrice();
    }

    private bool IsOptionConsistOf(ProductOption option)
    {
      return this.OrderItemOptions
                .Where(o => o.Product is SaleProduct)
                .Any(o => *1;
        }
    }
}

MEMO
以前お買い上げはイベントで、イベントは事実の記録であり変更されないようなことを書きましたが、このオプションの変更はどう考えればよいのでしょうか?
これはお買い上げがイベント以外に途中経過を記録する一時データの役割を兼任していると考えます。一時データが確定処理を経由して事実として記録されます。状態によって処理を変えたり処理自体を利用不可にする必要がある点は留意しておきます。

未完了なお買い上げの検証

だいぶ実装が進んできたのですがさらに実装を追加します。お買い上げにおいて購入商品のオプションは全て指定されていないとお買い上げができません。OrderItemに検証ロジックを実装し登録時にエラーになるようにします。

[TestMethod]
public void TestNotComplete()
{
    ...
        var setItem = myOrder.AddItem(products.FindSaleProduct("CheeseBurgerSet"));
        var setOption = setItem.Product.OptionList;
        setItem.Choice(setOption.FindByTitle("Main", "CheeseBurger"));
        orders.Add(myOrder);
        try
        {
            db.SubmitChanges();
            Assert.Fail();
        }
        catch (BurgerShopApplicationExcpetion ex)
        {
            Assert.AreEqual("ErrorNotCompelete", ex.MessageCode);
        }
    }

}


以前作成した検証処理の仕組みをそのまま適用するために、IValidatorインターフェースを実装します。

partial class OrderItem : IValidator
{
    ...
    public bool IsComplete()
    {
        return this.Product.HasAllItemOption(
            this.OrderItemOptions.Select(o => o.Option));
    }

    partial void OnValidate()
    {
        this.Validate();
    }

    public List GetErrors()
    {
        List errors = new List();
        if (!IsComplete()) errors.Add(new ApplicationError("ErrorNotComplete"));
        return errors;
    }
}
partial class SaleProduct
{
    ...
    public bool HasAllItemOption(IEnumerable options)
    {
        return this.ProductItems.All(
            item => options.Any( opt => item.IsConsistOf(opt)));
    }
}
partial class ProductItem
{
    ...
    public bool IsConsistOf(ProductOption target)
    {
        return GetOptionList().Contains(target);
    }
}

お買い上げ(Order)ドメインモデル クラス図

現在までに作成したお買い上げ(Order)のクラス図です。

*1:SaleProduct)o.Product).IsConsistOf(option) ); } private void ResetOption(string itemName) { var preOption = OrderItemOptions.SingleOrDefault(o => o.ItemName == itemName); if (preOption != null) { var subOpt = preOption.Product as SaleProduct; if (subOpt != null) ResetSubOption(subOpt); OrderItemOptions.Remove(preOption); } } private void ResetSubOption(SaleProduct subOpt) { foreach (var opt in subOpt.OptionList.Where( o => OrderItemOptions.Any(i => o.ItemName == i.ItemName))) { OrderItemOptions.Remove(OrderItemOptions.Single(o => opt.ItemName == o.ItemName