CSVファイルの読み書き設定をC#スクリプトで記述するWPFアプリをDesktop App Converterで変換してストアに公開しました

デスクトップアプリをUWPに変換してWindowsストアに公開可能な状態にするDesktop App Converterを試してみたい!
ということで、ブログのネタで作成していたWPFアプリ(CsvEditSharp)をDesktop App Converterに掛けてストアに公開するまでをチャレンジしてみました。

ひとまず、公開まで漕ぎつけることができたので、アプリの宣伝をしておきます。

CsvEditSharp

CsvEditSharpは、CSVファイルの読み書き設定をC#スクリプトで記述するCSVエディタです。
スクリプトでは、C#CSVファイルを扱うためのクラスライブラリCsvHelperAPIを利用して各種設定を記述します。

Windows Storeから無料でダウンロードできます。(Windows10 Anniversary Update 以降のデスクトップPCのみで利用可能)

www.microsoft.com

ソースコードGitHubに公開しています。

github.com

基本操作

設定スクリプトのひな型を生成してCSVファイルを読み込む

初めて扱うCSVファイル等、設定スクリプトが存在していない場合に、CSVファイルの読み込みと同時に設定スクリプトのひな型を生成することができます。

  • ツールバーの「Configuration Script」コンボボックスで "(Auto Genarate)" を選択します

f:id:pierre3:20170106142156p:plain

  • 「Open」ボタンをクリックして読み込むCSVファイルを選択します

  • 以下のダイアログで、自動生成される設定スクリプトの名前、CSVファイルのエンコーディングおよびヘッダレコードの有無を入力して[OK]をクリックします

f:id:pierre3:20161220135026p:plain

次のように、選択したCSVファイルのヘッダ情報を基に設定スクリプトが自動生成されます。

f:id:pierre3:20161220134928p:plain

レコード格納クラスがFieldData という名前で生成されます。

  • クラス内の各プロパティが1つのカラムを表します
  • ヘッダレコードに定義されているカラム名が、そのままプロパティ名となります
  • プロパティのデータ型は全て文字列(string)型となります
  • カラム名にプロパティ名として使用できない文字が含まれる場合、またはヘッダレコードが存在しない場合のプロパティ名にはcolumn_ + カラム番号が割り当てられます

設定スクリプトのひな型をカスタマイズする

設定スクリプトの編集

設定スクリプトを、読み込んだCSVファイルの内容に応じて書き換えます。

Encoding = Encoding.GetEncoding("utf-8");

//Genderの選択肢を定義するenum
enum Gender
{
    Male,
    Female
}

//レコード格納クラスの名前を変更
class Person
{
  //プロパティの型をデータの種類に応じて変更
    public string Name { get; set; }
    public DateTime Birthday { get; set; }
    public Gender Gender { get; set; }public bool Married { get; set; }
    public double PocketMoney { get; set; }
}

//クラスマッピングの設定も、扱うデータの種類に応じたものに変更
RegisterClassMap<Person>(classMap =>
{
    classMap.Map(m => m.Name).Name("Name");
    classMap.Map(m => m.Birthday).Name("Birthday")
        .TypeConverterOption("M/d/yyyy");
    classMap.Map(m => m.Gender).Name("Gender");
    classMap.Map(m => m.Married).Name("Married")
        .TypeConverterOption(true,"Y")
        .TypeConverterOption(false,"N");
        
    var culture = System.Globalization.CultureInfo.GetCultureInfo("en-us");
    classMap.Map(m => m.PocketMoney).Name("PocketMoney")
        .TypeConverterOption("C")
        .TypeConverterOption(NumberStyles.Currency)
        .TypeConverterOption(culture);
});

編集が完了したら[Run]ボタンをクリックして、結果を確認します。

f:id:pierre3:20170106150619p:plain

編集したコードに問題がなければ、編集後の設定でCSVファイルが再読み込みされ、内容がCSVエディタに表示されます f:id:pierre3:20161220135128p:plain

設定スクリプトの保存

編集した設定スクリプトは[Save] (上書き保存)ボタン、[SaveAs...] (名前を付けて保存)ボタンで保存できます。

[SaveAs...]ボタンをクリックすると、以下のダイアログが表示されます。

f:id:pierre3:20170107221931p:plain

  • 「Save as a new file」を選択

  • 「Save into the current directory as "Default.config.csx"」を選択

    • 設定スクリプトを、読み込み中のCSVファイルと同じフォルダに"Default.config.csx"という名前で保存します。
    • 読み込むCSVファイルと同じフォルダに"Default.config.csx"ファイルが存在する場合、常にこのファイルが設定スクリプトとして使用されます。(「Configuration Script」コンボボックスでの選択は無視されます)

設定スクリプトを指定してCSVファイルを読み込む

対応する設定スクリプトが既に存在する場合、「Configuration Script」で対応するスクリプトの名前を選択後、CSVファイルを読み込みます。

f:id:pierre3:20170107225016p:plain

設定スクリプトの管理

[Settings...]ボタンで表示される以下のダイアログで、作成済みの設定スクリプトの名前変更や削除が可能です。  

f:id:pierre3:20170110130931p:plain

値の編集

CSVエディタ(読み込んだCSVファイル名のタブ)内のセルを直接編集することができます。

クラスマッピングでマップしたプロパティのデータ型に応じて入力方法や入力可能な値が変わります。

  • データ型がenumの場合、enumのメンバーを選択肢としたコンボボックスで値を選択します
  • データ型がboolの場合、チェックボックスのON/OFFでTrue/Falseを切り替えます
  • データ型が数値型やDateTime型の場合、セルに入力した文字列が目的の型に変換できない場合にエラーメッセージを表示します。

f:id:pierre3:20170110224957p:plain

AddValidation() メソッドを使用してより詳細な入力検証を設定することも可能です。

編集したCSVデータの保存

ツールバーの[SaveAs]ボタンをクリックして、編集後のCSVデータを別のCSVファイルとして保存することができます。 CSVエディタに表示されている状態がそのまま保存されます。(Queryメソッドでフィルタ・ソートを行った場合も、表示されている内容がそのまま保存されます。)

f:id:pierre3:20170111104107p:plain

設定スクリプトAPI

Encoding プロパティ

Encoding Encoding { get; set; }

対象CSVファイルのエンコーディングを指定します。 省略した場合は Encoding.Defaultが割り当てられます。

Encoding = Encoding.GetEncoding("utf-8");

RegisterClassMap メソッド

void RegisterClassMap<T>();
void RegisterClassMap<T>(Action<CsvClassMap<T>> propertyMapSetter);
void RegisterClassMap<T>(Action<CsvClassMap<T>> propertyMapSetter, RegisterClassMapTarget target);

CsvHelperを利用したクラスマッピングの設定を記述します。

  • 登録するクラスマッピングオブジェクトを引数に取るデリゲートpropertyMapSetter内に設定内容を記述します。
  • 第2引数で、登録したクラスマッピングを使用するタイミングを指定できます。
    • RegisterClassMapTarget.Reader : 読み込み時のみ
    • RegisterClassMapTarget.Writer : 書き込み時のみ
    • RegisterClassMapTarget.Both : 読み書き両用

クラスマッピング設定の記述方法については、CsvHelper 公式ドキュメントのMappingのセクションを参照ください。

//既定のクラスマッピング設定を使用
RegisterClassMap<Person>();

//読み書き両用
RegisterClassMap<Person>(classMap => {
    classMap.Map(m => m.Name);
    classMap.Map(m => m.Birthday);
    classMap.Map(m => m.Gender);
    classMap.Map(m => m.Married)
        .TypeConverterOption(true,"Y")
        .TypeConverterOption(false,"N");
    classMap.Map(m => m.PocketMoney)
        .TypeConverterOption("C")
        .TypeConverterOption(NumberStyles.Currency);
});

//読み込み時のみ
RegisterClassMap<Person>(classMap => {
    classMap.Map(m => m.Name);
    classMap.Map(m => m.Birthday);
    classMap.Map(m => m.Gender);
    classMap.Map(m => m.Married)
        .TypeConverterOption(true,"Y")
        .TypeConverterOption(false,"N");
    classMap.Map(m => m.PocketMoney)
        .TypeConverterOption("C")
        .TypeConverterOption(NumberStyles.Currency);
}, RegisterClassMapTarget.Reader);

SetConfiguration メソッド

void SetConfiguration(Action<CsvConfiguration> configurationSetter);

引数configurationSetterデリゲートに渡されるCsvConfigurationオブジェクトの値を書き換えることで、CSVファイル読み書き時の詳細な設定を記述することができます。

ここで設定可能な項目の詳細は CsvHelper 公式ドキュメントのConfigurationのセクションを参照ください。

SetConfiguration(config =>
{
    config.HasHeaderRecord = false;
    config.AllowComments = true;
    config.Comment = '#';
    config.Delimiter = ';';
    //etc...
});

AddValidation メソッド

void AddValidation<TType, TMember>(Expression<Func<TType, TMember>> memberSelector, Func<TMember, bool> validation, string errorMessage);

値変更時の入力検証機能を追加することができます。

  • memberSelector デリゲート(式木)で対象となるカラムのプロパティを指定します。
  • validation デリゲートで検証を通過する条件を指定します。
  • errorMessage に検証NG時に表示する文字列を指定します。
AddValidation<Person,DateTime>(
    m => m.Birthday , 
    dt => dt <= DateTime.Now.Date,
    "Cannot enter a future date.");

AddValidation<Person, double>(
    m => m.PocketMoney , 
    n => (n > 0) && (n < 10000.0),
    "PocketMoney must be in the range $0 to $10000.");

Query メソッド

void Query<T>(Func<IEnumerable<T>, IEnumerable<T>> query);
void Query<T>(Action<IEnumerable<T>> query);

データのフィルタ、ソート

LINQの拡張メソッドを利用して、表示するデータのフィルタ、ソートを行うことができます。

Query<Person>(source => source
    .Where(m => m.Gender == Gender.Female )
    .Where(m => m.Married )
    .OrderBy(m => m.PocketMoney) );

データの一括更新

ForEach()拡張メソッドを使用して、データを書き換えることも可能です。

Query<Person>( record => record
    .Where( m => m.Gender == Gender.Male )
    .Where( m => m.Married )
    .ForEach( m =>
    {
        m.Name += " *";
        m.PocketMoney = 0;
    })
);

(Queryメソッドは、設定スクリプトのタブではなく、CSVデータ表示タブの下部にあるテキストエディタ内に記述します。下記のようなコードを記述後、[Execute]ボタンをクリックすることで、コードが実行され、表示に反映されます。)

f:id:pierre3:20170110161754p:plain

C#のソースコードからPlantUMLのクラス図を生成するアプリ(改)をリリースしました

サンプルコードの棚卸

以前、以下の記事で作成したC#ソースコードからPlantUMLを生成するサンプルプログラムですが、(長らく放置状態でしたが)
少し手直しをして、それなりに使えるようにしました。

pierre3.hatenablog.com

pierre3.hatenablog.com

 PlantUmlClassDiagramGenerator

リポジトリはこちら

github.com

こちらからバイナリ(.zip)をダウンロードできます。ぜひお試しください。

Release v0.5.0.0-beta

使い方

PlantUmlClassDiagramGeneratorはコンソールアプリケーションです。 以下の様にパラメータを指定して実行します。

C:\> PlantUmlClassDiagramGenerator.exe InputPath [OutputPath] [-dir] ^
 [-public | -ignore IgnoreAccessibilities] [-excludePaths ExcludePathList]
  • InputPath (必須)
    入力するソースコードのファイル名またはディレクトリ名を指定します。
    ディレクトリを指定した場合、サブフォルダ以下を含めた全ての.csファイルが変換の対象となります。
  • OutputPath (省略可)
    出力先のファイル名またはディレクトリ名を指定します。
    省略した場合、変換元の.csファイルと同じディレクトリに変換後の.pumlファイルが出力されます。
  • -dir (省略可)
    InputPath および OutputPath がディレクトリ名の場合にこのオプションを指定します。
  • -public (省略可)
    クラス、構造体のパブリックメンバーのみを出力する場合に指定します。
  • -ignore (省略可)
    出力対象外とするアクセシビリティをカンマ区切りで指定します。
    (例): -ignore private,protected
  • -excludePaths (省略可)
    除外するファイル名またはディレクトリ名を指定します。-dir オプションを指定した場合のみ有効
    (例): -excludePaths obj,Properties\AssemblyInfo.cs

使用例

例として、こちらのソースコードを変換してみます。

github.com

ソリューションディレクトリをC:\Source\CsvEditSharpとして、プロジェクトCsvEditSharp以下の.csファイルをまとめて変換する場合を例にします。
出力先はC:\Source\CsvEditSharp\Documents\umlとします。

C:\> PlantUmlClassDiagramGenerator.exe C:\Source\CsvEditSharp\CsvEditSharp ^
 C:\Source\CsvEditSharp\Documents\uml -dir -public -excludePaths obj,Properties 

今回は、パブリックメンバーのみを出力対象としました。
また、objフォルダ内に自動生成される.cs ファイルと、Propertiesフォルダ内のAssemblyInfo.csファイルは変換対象から除外するようにしました。

変換結果は以下で確認できます。

CsvEditSharp/Documents/uml at master · pierre3/CsvEditSharp · GitHub

ちなみに、ディレクトリ単位で変換を行った場合、全ての出力ファイルを !include で参照したinclude.pumlをInputPath内に出力するようにしています。

@startuml
!include .\\App.xaml.puml
!include .\\Models\ColumnValidation.puml
!include .\\Models\CompletionData.puml
!include .\\Models\CsvConfigFileManager.puml
!include .\\Models\CsvEditSharpConfigurationHost.puml
!include .\\Models\CsvEditSharpWorkspace.puml
!include .\\Models\CustomBooleanConverter.puml
!include .\\Models\EnumerableExt.puml
!include .\\Models\GenerateConfigSettings.puml
!include .\\Models\ICsvEditSharpConfigurationHost.puml
!include .\\Models\RegisterClassMapTarget.puml
!include .\\Models\SaveConfigSettings.puml
//...(中略)...
!include .\\ViewModels\GenerateConfigDialogViewModel.puml
!include .\\ViewModels\MainWindowViewModel.puml
!include .\\ViewModels\SaveConfigDialogViewModel.puml
!include .\\Views\GenerateConfigDialog.xaml.puml
!include .\\Views\MainWindow.xaml.puml
!include .\\Views\SaveConfigDialog.xaml.puml
@enduml

これをそのままPlantUMLで画像に変換すると、こんな感じになります↓

http://pierre3net.azurewebsites.net/content/image/include.svg

主な変更点

以前の記事に書いた仕様からの主な変更点は以下の通りです。

ネストクラスの扱い

ネストされたクラスは、ネストのまま変換されてPlantUMLでエラーとなってしまっていましたが、
今回、ネストクラスは外に展開して、+-- で関連付けするように修正しました。

class OuterClass 
{
  class InnerClass 
  {
    struct InnerStruct 
    {

    }
  }
  • PlantUML
class OuterClass{

}
class InnerClass{

}
<<struct>> class InnerStruct {

}
OuterClass +- InnerClass
InnerClass +- InnerStruct

f:id:pierre3:20161217211654p:plain

ジェネリクス型の変換

PlantUMLでは、ジェネリクス型は型引数の数が異なっても同じクラスとして認識されてしまうようです。

class GenericsType {
}
class GenericsType<T1> {
}
class GenericsType<T1,T2> {
}

PlantUMLでは、上記は全て GenericsType クラスとして認識され、ダイアグラムは最後に記述した定義で上書きされてしまいます。

そこで今回は、以下の様に"クラス名に型引数の数を付加したもの"を変換後のクラス名とするようにしました。

class "GenericsType`1"<T1>{
}
class "GenericsType`2"<T1,T2>{
}

f:id:pierre3:20161217211751p:plain

継承関係

クラス、インターフェースを継承しているクラスは、ベースクラスと<|--で結んで継承関係を表すようにしました。

abstract class BaseClass
{
    public abstract void AbstractMethod();
    protected virtual int VirtualMethod(string s) => 0;
}
class SubClass : BaseClass
{
    public override void AbstractMethod() { }
    protected override int VirtualMethod(string s) => 1;
}

interface IInterfaceA {}
interface IInterfaceA<T>:IInterfaceA
{
    T Value { get; }
}
class ImplementClass : IInterfaceA<int>
{
    public int Value { get; }
}
  • PlantUML
abstract class BaseClass {
    + {abstract} AbstractMethod() : void
    # <<virtual>> VirtualMethod(s:string) : int
}
class SubClass {
    + <<override>> AbstractMethod() : void
    # <<override>> VirtualMethod(s:string) : int
}
interface IInterfaceA {
}
interface "IInterfaceA`1"<T> {
    Value : T <<get>>
}
class ImplementClass {
    + Value : int <<get>>
}
BaseClass <|-- SubClass
IInterfaceA <|-- "IInterfaceA`1"
"IInterfaceA`1" "<int>" <|-- ImplementClass

f:id:pierre3:20161217211830p:plain

C#スクリプト エディタに入力補完機能を実装する

この記事は、以下の記事の続きとなります。

この記事のサンプルコードはこちら github.com

これまでは、読み込んだ設定スクリプトをTextBoxコントロールに表示していたのですが、
今回、もう少し"コードエディタっぽい" エディタに変更して、ソースコードの表示・編集が出来るようにしたいと思います。

目次

C#スクリプトエディタにAvalonEditを使う

AvalonEditは、WPF用の高機能テキストエディタライブラリで、ソースコードシンタックスハイライトや入力補完機能を備えています。
今回は設定スクリプトの表示、編集用にこのライブラリを使用したいと思います。

AvalonEdit by icsharpcode

NuGet からインストール可能です。

PM> Install-Package AvalonEdit

XAML での使用例は以下の通りです。
AvalonEdit:TextEditor というコントロールを配置します。シンタックスハイライトや、行番号の表示有無などが指定可能です。

<Window x:Class="CsvEditor.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:CsvEditor"
        xmlns:avalonEdit="http://icsharpcode.net/sharpdevelop/avalonedit"
        mc:Ignorable="d"
        Title="MainWindow" Height="580" Width="960">
    <Grid>
        <!-- 中略 -->
        <avalonEdit:TextEditor 
            x:Name="configEdit"
            Document="{Binding ConfigurationDoc,Mode=TwoWay}"
            SyntaxHighlighting="C#"
            ShowLineNumbers="True"
            FontFamily="Consolas" FontSize="10Pt">
        </avalonEdit:TextEditor>
        <!-- 中略 -->
    </Grid>
</Window>

C#スクリプトエディタに入力補完機能を付ける

AvalonEdit の入力補完ウィンドウ

AvalonEdit には、入力補完ウィンドウを表示する仕組みが備わっています。

公式ドキュメント(http://avalonedit.net/documentation/ の"Code Completion")のサンプルを参考に以下のような記述を行うだけで、補完ウィンドウの表示自体は簡単に実装することが可能です。

//MainWindowのコードビハインド
public partial class MainWindow : Window
{
    private CompletionWindow completionWindow;

    public MainWindow()
    {
        InitializeComponent();
        //イベントハンドラを登録
        configEdit.TextArea.TextEntered += TextArea_TextEntered;
        configEdit.TextArea.TextEntering += TextArea_TextEntering;
    }
    //文字入力中
    private void TextArea_TextEntering(object sender, System.Windows.Input.TextCompositionEventArgs e)
    {
        //補完Windowが開いている間
        if (e.Text.Length > 0 && completionWindow != null)
        {
            //英数字以外が入力された場合
            if (!char.IsLetterOrDigit(e.Text[0]))
            {
                //選択中のリストの項目をエディタに挿入する                
                completionWindow.CompletionList.RequestInsertion(e);
            }
        }
        // HandledはTrueにしない
        // e.Handled=true;
    }
    //文字入力後
    private async void TextArea_TextEntered(object sender, System.Windows.Input.TextCompositionEventArgs e)
    {
        // ピリオドを入力
        if (e.Text == ".")
        {
            //入力補完Windowを生成
            completionWindow = new CompletionWindow(configEdit.TextArea);
            //補完リストに表示するアイテムをコレクションに追加する
            //---> ここは、編集内容に応じて適切な入力候補を追加するように書き換える!
            IList<ICompletionData> data = completionWindow.CompletionList.CompletionData;
            data.Add(new CompletionData("item1"));
            data.Add(new CompletionData("item2"));
            data.Add(new CompletionData("item3"));
            //<---
            //Windowを表示
            completionWindow.Show();
            completionWindow.Closed += delegate
            {
                completionWindow = null;
            };
        }
    }
}
  • 入力候補一覧には、ICompletionData を実装したクラスのインスタンスを追加してあげる必要があります。
public class CompletionData : ICompletionData
{
    //入力候補一覧(UI)に表示する内容
    public object Content { get; set; }
    //ツールチップに表示する説明文
    public object Description { get; set; }
    //入力候補の左側に表示するアイコン
    public ImageSource Image { get; set; }
    //表示順の優先度?
    public double Priority { get; set; }
    //アイテム選択時に挿入される文字列
    public string Text { get; set; }
    //アイテム選択後の処理
    public void Complete(TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs)
    {
        textArea.Document.Replace(completionSegment, Text);
    }
}

Roslyn Workspaces API の Recommender を利用して入力候補一覧を取得する

AvalonEdit を利用することで、入力補完Window 自体を表示することができるのは分かったのですが、肝心の入力候補の一覧はどうやって作ればよいのでしょうか?

Roslyn の Workspaces API 辺りにコード補完の機能があったような。。。
と調べてみると、どうやら Microsoft.CodeAnalysis.Recommendations.Recommender クラスのGetRecommendedSymbolsAtPositionAsync というメソッドが使えそうだという事が分かりました。

Roslyn Workspases API もNuGetで追加します。

PM> Install-Package Microsoft.CodeAnalysis.Workspaces

Recommender.GetRecommendedSymbolsAtPositionAsync()

(このメソッドに関する情報が非常に少ないため、メソッド名と戻り値等から予測すると)
このメソッドは、ソースコード上の指定した場所(position)におけるコード補完候補の一覧を IEnumerable<ISymbol> で返してくれるメソッドのようです。

シグネチャは以下の通り。引数にはposition のほかに、SemanticModel と Workspace を渡す必要があるようです。

public static Task<IEnumerable<ISymbol>> GetRecommendedSymbolsAtPositionAsync(
    SemanticModel semanticModel,
    int position,
    Workspace workspace,
    OptionSet options = null,
    CancellationToken cancellationToken = default(CancellationToken));
(引数1) semanticModel

SemanticModelは、C#スクリプトを実行する際に作成する Script<T>オブジェクトから取得できるCompilation オブジェクトから取得可能なので、これを指定すれば良さそうです。

private void CompileScript(string code)
{
    Script<object> script = CSharpScript.Create(code, ScriptOptions.Default
        .WithReferences(
            typeof(object).Assembly,
            typeof(CsvReader).Assembly,
            typeof(ICsvEditorConfigurationHost).Assembly)
        .WithImports(
            "System",
            "System.Collections.Generic",
            "System.Linq"),
        typeof(ICsvEditorConfigurationHost));
    //SemanticModel の取得
    var compilation = script.GetCompilation();
    var syntaxTree = compilation.SyntaxTrees.First();
    var semanticModel = compilation.GetSemanticModel(syntaxTree);
    //Workspaceの作成
    //...(略)
}
(引数2) positon

ソースコード上の位置(エディタで補完機能を呼び出した際のキャレット位置)を指定すれば良さそう。

(引数3) workspace

ソースコードはWorkspace の形で渡してあげる必要があります。

Workspace を一から作成するには AdhocWorkspace クラスを使用します。

//Workspaceの作成
Workspace workspace = new AdhocWorkspace();
//空のprojectを作成
string projectName = "CsvEditorConfig";
ProjectId projectId = ProjectId.CreateNewId();
VersionStamp versionStamp = VersionStamp.Create();
ProjectInfo projectInfo 
    = ProjectInfo.Create(projectId, versionStamp, projectName, projectName, LanguageNames.CSharp);
Project project = workspace.AddProject(projectInfo);
//Documentを追加
SourceText sourcetext = Microsoft.CodeAnalysis.Text.SourceText.From(code);
workspace.AddDocument(project.Id, "Config.csx", sourcetext);

ICompletionData のコレクションを作成する

AvalonEdit の CompletionWindow に渡す入力候補ICompletionData インターフェースを実装したオブジェクトである必要がありました。

次は、GetRecommendedSymbolsAtPositionAsync() メソッドから取得したISymbolのコレクション を ICompletionData のコレクションに変換して返すメソッドを作ります。

public class MainWindowViewModel : BindableBase
{
    private Script<object> script;
    private SemanticModel semanticModel;
    private AdhocWorkspace workspace = new AdhocWorkspace();
    
    public async Task<IEnumerable<CompletionData>> GetCompletionListAsync(int position,string code)
    {
        CompileScript(code);
        if (semanticModel == null || workspace == null)
        {
            return Enumerable.Empty<CompletionData>();
        }
        var items = await Recommender.GetRecommendedSymbolsAtPositionAsync(
            semanticModel, position, workspace);
        return items.Select(symbol => new CompletionData()
        {
            //ListBoxに表示する内容
            Content = symbol.Name,
            //エディタに挿入する文字列
            Text = symbol.Name,
            //ツールチップに表示する文字列
            Description = symbol.ToMinimalDisplayString(semanticModel, position)
        });
    }
}

これをViewModelに実装して、View側 (コードビハインド) に記述した TextArea_TextEntered イベントハンドラで呼び出すようにします。
また、ピリオド(.)のほかに[Ctrl]+[Space]キーでも入力補完ウィンドウを表示するようにOnKeyDown() メソッドをオーバーライドします。

public partial class MainWindow : Window
{
    private CompletionWindow completionWindow;
    private MainWindowViewModel VM { get { return DataContext as MainWindowViewModel; } }

    private async void TextArea_TextEntered(object sender, 
        System.Windows.Input.TextCompositionEventArgs e)
    {
        if (VM == null) { return; }
        // Open code completion after the user has pressed dot:
        if (e.Text == ".")
        {
            await ShowCompletionWindow();
        }
    }

    protected override async void OnKeyDown(KeyEventArgs e)
    {
        base.OnKeyDown(e);
        //Ctrl+Space でも表示するように
        if (e.Key == Key.Space && e.KeyboardDevice.Modifiers.HasFlag(ModifierKeys.Control))
        {
            e.Handled = true;
            await ShowCompletionWindow();
        }
    }    

    private async Task ShowCompletionWindow()
    {
        if (VM == null) { return; }
        completionWindow = new CompletionWindow(configEdit.TextArea);
        IList<ICompletionData> data = completionWindow.CompletionList.CompletionData;

        var completionItems = await VM.GetCompletionListAsync(
            configEdit.TextArea.Caret.Offset + 1,
            configEdit.Document.Text + " ");

        foreach (var item in completionItems)
        {
            data.Add(item);
        }
        completionWindow.Show();
        completionWindow.Closed += delegate
        {
            completionWindow = null;
        };
    }
}

実行してみよう

長くなってしまいましたが、これで一通りの実装が完了しました。
それでは、実行結果を見てみましょう。

f:id:pierre3:20160727223655g:plain

  • ソースコードの編集箇所に応じた入力候補がちゃんと表示されています。
  • デリゲートの中の引数でも補完が利くようです。

中々いい感じに動いていますね。

※ あとは、以下の2点を何とかすれば完璧なのですが、今回はあきらめました。

まとめ

  • AvalonEdit は 高機能なテキストエディタを簡単に実装できていいぞ!
  • コンテキストに応じた入力候補の取得には Microsoft.CodeAnalysis.Recommendations.Recommender が使えるよ!

CSVのクラスマッピングの定義をC#スクリプトで記述する (その3: ValidationRule)

この記事は、以下の記事の続きです

github.com

目次

入力可能なデータの条件を指定できるようにする

前回は、CsvHelperのテキスト変換処理(TypeConverter)ラップしたConverterを設定することで、DataGridColumn の変換処理をカスタマイズしました。
今回は、DataGridColumnの検証(Validation)機能を利用して、カラム毎に入力値の制限を付けられるようにしたいと思います。

C#スクリプト側の記述

スクリプト側に公開するインターフェースに以下のメソッドを追加して、カラム(データ格納クラスのプロパティ)毎に入力制限を設定できるようにします。

public interface ICsvEditorConfigurationHost
{
    void AddValidation<TType,TMember>(
        Expression<Func<TType, TMember>> memberSelector,
        Func<TMember, bool> validation,
        string errorMessage);
}
  • 第1引数: 式木(デリゲート)でデータ格納クラスの対象プロパティを指定
  • 第2引数:「対象プロパティの値を受け取りboolを返すデリゲート」で、入力可能な値の条件を指定
  • 第3引数: 入力制限に引っかかった場合のエラーメッセージを指定

スクリプト内での記述例は以下の通り。(前回の記事と同じサンプルデータを使用)

//生年月日は今日以前のみ
AddValidation<Person, DateTime>(prop => prop.Birthday,
    m => m <= DateTime.Today,
    "未来の日付は入力できません");

//お小遣いは ¥0 以上 ¥10,000 以下
AddValidation<Person, int>(prop => prop.PocketMoney,
    m => (m >= 0) && (m <= 10000),
    "入力可能な範囲は ¥0~¥10,000 です。");

AddValidation メソッドの実装は以下の通りです。 引数に渡した設定値をプロパティ名をキーとした辞書に登録します。

public class CsvEditorConfigurationHost
{
    public IDictionary<string, ColumnValidation> ColumnValidations { get; } 
        = new Dictionary<string, ColumnValidation>();

    public void AddValidation<TType, TMember>(Expression<Func<TType, TMember>> memberSelector, 
        Func<TMember, bool> validation, string errorMessage)
    {
        //式木からプロパティ名を取得
        MemberExpression memberExpression = null;
        if (memberSelector.Body.NodeType == ExpressionType.Convert)
        {
            var body = (UnaryExpression)memberSelector.Body;
            memberExpression = body.Operand as MemberExpression;
        }
        else if (memberSelector.Body.NodeType == ExpressionType.MemberAccess)
        {
            memberExpression = memberSelector.Body as MemberExpression;
        }
        if (memberExpression == null)
        {
            throw new ArgumentException("Not a member access", nameof(memberSelector));
        }
        //プロパティ名をキーにして辞書に登録
        ColumnValidations.Add(memberExpression.Member.Name, 
            new ColumnValidation(m => validation((TMember)m), errorMessage));
    }
}
//設定値格納用
public class ColumnValidation
{
    public Func<object, bool> Validation { get; }
    public string ErrorMessage { get; }
    public ColumnValidation(Func<object, bool> validation, string errorMessage)
    {
        Validation = validation;
        ErrorMessage = errorMessage;
    }
}

DataGridColumn に ValidationRuleを設定する

スクリプトで指定された条件からDataGridColumnバインドするValidationRule クラスを作成します。

public class DataGridColumnValidationRule : ValidationRule
{
    private Func<object, bool> isValidate;
    private object errorContent;

    public DataGridColumnValidationRule(Func<object, bool> isValidate, object errorContent)
    {
        if (isValidate == null) { throw new ArgumentNullException(nameof(isValidate)); }
        if (errorContent == null) { throw new ArgumentNullException(nameof(errorContent)); }

        ValidationStep = ValidationStep.ConvertedProposedValue;
        this.isValidate = isValidate;
        this.errorContent = errorContent ?? "invalid value";
    }

    public override ValidationResult Validate(object value, CultureInfo cultureInfo)
    {
        return (isValidate(value)) ?
            ValidationResult.ValidResult :
            new ValidationResult(false, errorContent);
    }
}

前回のConverterの指定と同様にAutoGeneratingColumnイベントのハンドラ内でDataGridColumnのBindingオブジェクトにValidationRuleを追加します。

public class MainWindow
{
    private void DataGrid_AutoGeneratingColumn(object sender, DataGridAutoGeneratingColumnEventArgs e)
    {
        if (VM == null) { return; }

        var converter = VM.GetDataGridColumnConverter(e.PropertyName);
        if (converter == null) { return; }

        if (!string.IsNullOrEmpty(converter.HeaderName))
        {
            e.Column.Header = converter.HeaderName;
        }

        var textColumn = e.Column as DataGridTextColumn;
        if (textColumn != null)
        {
            textColumn.EditingElementStyle = (Style)Resources["textColumnStyle"];
        }

        var binding = (e.Column as DataGridBoundColumn)?.Binding as Binding;
        if (binding != null)
        {
            binding.Converter = converter;
            //VMからValidationRuleを取得してBindingに設定        
            var validationRule = VM.GetDataGridColumnValidation(e.PropertyName);
            if (validationRule != null)
            {
                binding.ValidationRules.Add(validationRule);
            }
        }
    }
}

public class MainWindowViewModel
{
    public DataGridColumnValidationRule GetDataGridColumnValidation(string propertyName)
    {
        ColumnValidation columnValidaiton;
        if (host.ColumnValidations.TryGetValue(propertyName, out columnValidaiton))
        {
            return new DataGridColumnValidationRule(columnValidaiton.Validation, columnValidaiton.ErrorMessage);
        }
        return null;
    }
}

実行例

「生年月日」と「お小遣い」に入力可能範囲外の値を入力してみます。

f:id:pierre3:20160722230823p:plain

期待通りセルの背景が赤くなり、ツールチップには指定したエラーメッセージが表示されています。

まとめ

これで入力ミスも柔軟にチェックすることが出来るようになりました。

CSVのクラスマッピングの定義をC#スクリプトで記述する (その2:Converter)

この記事は、以下の記事の続きです。

pierre3.hatenablog.com

  • この記事のサンプルコードはこちら

github.com

目次

前回は、CSVデータのクラスマッピングC#スクリプトで記述してC#のオブジェクトに格納するところまでやりました。
そのオブジェクトをWPFのDataGridコントロールバインドして、読み取ったデータが表示されることも確認できました。

ただ、このままではDataGridのセルに表示されるデータが元のフォーマットと異なって表示されてしまう場合があります。

DataGridの表示フォーマットを何とかしたい

今回は、以下のCSVデータを使用します。

名前,生年月日,配偶者,性別,お小遣い
佐藤,1986/9/2,なし,男性,\1000
鈴木,2001/8/10,なし,男性,\500
高橋,1974/6/7,あり,女性,\300
田中,1990/11/20,なし,男性,\1000
伊藤,1986/10/10,なし,男性,\2000
渡辺,1968/5/5,なし,女性,\800
山本,1970/9/3,あり,女性,\9000
中村,1984/3/15,なし,男性,\10000
小林,2005/1/2,なし,男性,\100
加藤,1955/6/10,あり,男性,\0
吉田,1986/9/12,あり,男性,\800
山田,1996/3/25,なし,女性,\500
佐々木,1986/8/3,なし,男性,\500
山口,1986/12/6,あり,女性,\6000
松本,1980/2/2,あり,男性,\8000
井上,1992/7/17,あり,女性,\9000
木村,1989/11/6,なし,男性,\10000
林,1999/10/2,あり,男性,\200
斎藤,2010/4/21,なし,女性,\1000
清水,1980/9/7,あり,男性,\600

このデータを読み込んで生成したオブジェクトをそのまま表示すると以下のようになります。

f:id:pierre3:20160712223559p:plain

期待する表示内容とは異なる表示になっている箇所がいくつかあります。

  • カラムヘッダがデータ格納クラスのプロパティ名になっている
    ⇒ 読み込んだCSVファイルのヘッダ名を表示したい
  • 「生年月日」に時刻が含まれています。年月日の並びもおかしい
    yyyy/M/d のフォーマットで表示したい
  • 「お小遣い」の¥(\)マークが表示されない
    ¥10,000 の様に表示させたい

CsvClassMap で指定した文字列のフォーマットや変換処理をDataGridの表示に流用する

CSV読み書き時に使用するCsvClassMap<T>C#スクリプトで以下のように記述しました。

RegisterClassMap<Person>(classMap =>
{
    classMap.Map(m => m.Name).Name("名前");
    classMap.Map(m => m.Birthday).Name("生年月日")
        .TypeConverterOption("yyyy/M/d");
    classMap.Map(m => m.Married).Name("配偶者")
        .TypeConverterOption(true, "あり")
        .TypeConverterOption(false, "なし");
    classMap.Map(m => m.Sex).Name("性別");
    classMap.Map(m => m.PocketMoney).Name("お小遣い")
        .TypeConverterOption(NumberStyles.Currency)
        .TypeConverterOption("C");
});

DataGridに表示される形式は、CSVファイルに保存される形式と合わせたいのですが、 DataGridの表示フォーマットのために上記のような記述を2度も書くのはいやですよね。

ここはどうにかして、CsvClassMap の設定値を使いまわす方法を考えたいと思います。

CsvClassMap を覗いてみる

CsvClassMap のメンバーを確認してみます。

カラムごとの設定は PropertyMaps というコレクションに入っているPropertyMapクラスのDataプロパティに格納されているようです。

var propertyMap = csvClassMap.PropertyMaps
    .FirstOrDefault(m.Data.Property.Name == propertyName);
var propertyMapData = propertyMap.Data;

このプロパティはCsvPropertyMapData というクラスで、以下のようなメンバーを持ちます(抜粋)。

//Property(カラム)毎のマッピング設定を格納するクラス
public class CsvPropertyMapData
{
    //プロパティ情報
    public virtual PropertyInfo Property { get; }
    //カラムヘッダ名(コレクション)
    public virtual CsvPropertyNameCollection Names { get; }
    //ヘッダ名のインデックス        
    public virtual int NameIndex { get; set; }
    //カラムのインデックス
    public virtual int Index { get; set; }
    //テキスト⇔オブジェクト コンバータ
    public virtual ITypeConverter TypeConverter { get; set; }
    //コンバータでの変換時に使用するオプション
    public virtual TypeConverterOptions TypeConverterOptions { get; }
    //変換できなかった場合の規定値
    public virtual object Default { get; set; }
}
//テキスト⇔オブジェクト間の変換で使用するオプション
public class TypeConverterOptions
{
    public CultureInfo CultureInfo { get; set; }
    public DateTimeStyles? DateTimeStyle { get; set; }
    public TimeSpanStyles? TimeSpanStyle { get; set; }
    public NumberStyles? NumberStyle { get; set; }
    public List<string> BooleanTrueValues { get; }
    public List<string> BooleanFalseValues { get; }
    public string Format { get; set; }
}
  • カラムヘッダには NamesNameIndex が使えそうです。
  • データの変換処理は、TypeConverter で行います。これは(特に指定しなければ)対応するデータの型に応じたコンバータが自動で割り当てられます。
  • 文字列のフォーマットやカルチャの設定等は TypeConverterOptions に格納されます。これはClassMap 登録時に.TypeConverterOption() メソッドで設定します。

CsvClassMapのTypeConverter(CsvHelper)を IValueConverterWPF) にラップしてDataGridColumn に設定する。

DataGridのカラムごとの変換処理は、 DataGridColumn のBindingプロパティに指定するコンバータを置き換えることで変更可能です。

コンバータはIValueConverter を継承したクラスである必要があります。

以下に作成したコンバータを示します。中身は、CsvClassMapの TypeConverter をそのまま呼び出すだけのシンプルなものです。

public class DataGridColumnConverter : IValueConverter
{
    private ITypeConverter typeConverter;
    private TypeConverterOptions converterOptions;
    public string HeaderName { get; }
    public DataGridColumnConverter(string headerName, ITypeConverter converter, 
        TypeConverterOptions options)
    {
        typeConverter = converter ?? new DefaultTypeConverter();
        converterOptions = options ?? new TypeConverterOptions();
        HeaderName = headerName;
    }

    public object Convert(object value, Type targetType, 
        object parameter, CultureInfo culture)
    {
        if (targetType != typeof(string)) { return value; }
        try
        {
            return typeConverter.ConvertToString(converterOptions, value);
        }
        catch
        {
            //変換に失敗した場合はDependencyProperty.UnsetValue を返す
            return DependencyProperty.UnsetValue;
        }
    }

    public object ConvertBack(object value, Type targetType, 
        object parameter, CultureInfo culture)
    {
        var oldValue = value as string;
        if (oldValue == null) { return value; }
        try
        {
            return typeConverter.ConvertFromString(converterOptions, oldValue);
        }
        catch
        {
            //変換に失敗した場合はDependencyProperty.UnsetValue を返す
            return DependencyProperty.UnsetValue;
        }
    }
}

コンバータ作成部分の処理は以下のようになります。ClassMapから propertyName に対応する PropertyMap を取得して、そのTypeConverterおよびTypeConverterOptions を用いてコンバータを生成します。

public class MainWindowViewModel : BindableBase
{
    public DataGridColumnConverter GetDataGridColumnConverter(string propertyName)
    {
        var propertyMap = host.ClassMap.PropertyMaps
            .FirstOrDefault(m => m.Data.Property.Name == propertyName);
        if (propertyMap == null) { return null; }

        return new DataGridColumnConverter(
            propertyMap.Data.Names[propertyMap.Data.NameIndex],
            propertyMap.Data.TypeConverter,
            propertyMap.Data.TypeConverterOptions,
            propertyMap.Data.Default);
    }
}

自動生成されるDataGridColumn をカスタマイズする

DataGrid のカラムは、バインドするデータに合わせて自動生成されます。
自動生成されるカラムはAutoGeneratingColumn イベント内でカスタマイズすることができます。

<Grid>
    <DataGrid Grid.Column="0" 
        AutoGenerateColumns="True" 
        AutoGeneratingColumn="DataGrid_AutoGeneratingColumn" 
        ItemsSource="{Binding Items}" >
    </DataGrid>
</Grid>

AutoGeneratingColumn イベントは、DataGrid.ItemsSourceプロパティが変更されて、個々のカラムが自動生成される際に発行されます。

カラムのカスタマイズ処理は、コードビハインドに追加した DataGrid_AutoGeneratingColumn イベントハンドラ内に記述します。

イベント引数 DataGridAutoGeneratingColumnEventArgs e のプロパティを用いて次のように設定します。
(e.Column を強引にキャストしてBindingを取得するなど、少々お行儀が悪いですが。。。)

  • e.PropertyName から 対応するコンバータを取得
  • e.Column.Header に 表示するカラム名を設定
  • e.Column から Binding オブジェクトを取得して、コンバータを設定する
  • e.Column が DataGridTextColumn だったら EditingElementStyle にXAML側で記述した変換エラー時のStyleを設定
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent()
        DataContext = new MainWindowViewModel();
    }
    //AutoGeneratingColumn イベントハンドラ
    private void DataGrid_AutoGeneratingColumn(object sender, DataGridAutoGeneratingColumnEventArgs e)
    {
        var vm = DataContext as MainWindowViewModel;
        if (vm == null) { return; }

        //コンバータを取得
        var converter = vm.GetDataGridColumnConverter(e.PropertyName);
        if (converter == null) { return; }

        if (!string.IsNullOrEmpty(converter.HeaderName))
        {
            //カラムヘッダの文字列を変更
            e.Column.Header = converter.HeaderName;
        }

        var textColumn = e.Column as DataGridTextColumn;
        if (textColumn != null)
        {
            //変換エラー時にToolチップを表示するスタイルを設定
            textColumn.EditingElementStyle = (Style) Resources["textColumnStyle"];
        }

        //コンバータを設定
        var binding = (e.Column as DataGridBoundColumn)?.Binding as Binding;
        if (binding != null)
        {
            binding.Converter = converter;
        }
            
    }
}
<!-- セル編集で変換エラー発生時のスタイル -->
<Window.Resources>
    <Style x:Key="textColumnStyle" TargetType="{x:Type TextBox}">
        <Style.Triggers>
            <Trigger Property="Validation.HasError" Value="True">
                <!-- 背景を赤くする -->
                <Setter Property="Background" Value="Red"/>
                <!-- ツールチップを表示する -->
                <Setter Property="ToolTip">
                    <Setter.Value>
                        <Binding
                                Path="(Validation.Errors)[0].ErrorContent"
                                RelativeSource="{x:Static RelativeSource.Self}"/>
                    </Setter.Value>
                </Setter>
            </Trigger>
        </Style.Triggers>
    </Style>
</Window.Resources>

実行結果

ひとまずこれで準備が整いました。では、実行してみましょう。

  • [Read]を押して読み込んだ結果

f:id:pierre3:20160716161808p:plain

  • ちゃんと元データのカラム名が表示されています。
  • 「生年月日」がyyyy/M/d フォーマットで表示されています。
  • 「お小遣い」に¥マークがついています。(int.ToString("C")のフォーマット)

いい感じです。 では、変換できない文字を入力した場合はどうなるか見てみましょう。

f:id:pierre3:20160716214351p:plain

f:id:pierre3:20160716215040p:plain

f:id:pierre3:20160716214346p:plain

f:id:pierre3:20160716214532p:plain

ちゃんと XAMLで書いたStyle通りにセルの背景が赤くなり、エラーメッセージがツールチップに表示されました!

まとめ

CsvHelperのTypeConverter をラップした IValueConverter を使う事でDataGridの表示形式を元のCSVファイルと合わせることが出来ました。
あとは、カラム毎に「数値の入力可能範囲」や、「入力可能な文字」等の細かなルールを指定できると良いですよね。

という事で、次回はその辺りの実装を考えてみたいと思います。

CSVのクラスマッピングの定義をC#スクリプトで記述する

目次

WPF DataGrid でCSVエディタを作る。

WPF のDataGridコントロールに、読み込んだCSVデータを格納したコレクションをバインドするだけで、CSVエディタのようなものを簡単に作ることが出来ます。

例えば、以下のようなCSVデータを読み取る場合、

名前,生年月日,配偶者,性別
佐藤,1986/9/2,なし,男性
鈴木,2001/8/10,なし,男性
高橋,1974/6/7,あり,女性
田中,1990/11/20,なし,男性
伊藤,1986/10/10,なし,男性
渡辺,1968/5/5,なし,女性
山本,1970/9/3,あり,女性
中村,1984/3/15,なし,男性
小林,2005/1/2,なし,男性
加藤,1955/6/10,あり,男性
吉田,1986/9/12,あり,男性
山田,1996/3/25,なし,女性
佐々木,1986/8/3,なし,男性
山口,1986/12/6,あり,女性
松本,1980/2/2,あり,男性
井上,1992/7/17,あり,女性
木村,1989/11/6,なし,男性
林,1999/10/2,あり,男性
斎藤,2010/4/21,なし,女性
清水,1980/9/7,あり,男性

データ格納用のクラスを用意して、DataGridにバインド

//データ格納クラス
class Person
{
    public string Name { get; set; }
    public DateTime Birthday { get; set; }
    public bool Married { get; set; }
    public Sex Sex { get; set; }
}

enum Sex
{
    男性,
    女性
}

class MainWindowViewModel
{
    //バインディングソース
    public ObservableCollection<Person> Items{ get; set; }
    
    public void Read()
    {
        //データ読み込みとクラスへのマッピングはライブラリ任せにする(後述します)
        using (var baseReader = StreamReader("test.csv"))
        using (var reader = new CsvReader(baseReader))
        {
            Items = new ObservableCollection<Person>(reader.GetRecords<Person>());
        }
    }   
}
<DataGrid AutoGenerateColumns="True" ItemsSource="{Binding Items}" />

これで以下の様に読み込んだデータが一覧表示されるようになります。

f:id:pierre3:20160625214742p:plain

CsvHelperにCSVデータの読み込みとクラスにマップをお任せする

CSVデータの読み込み処理と、クラスへマッピングしてオブジェクトを生成する処理は、CsvHelper を使用すると便利です。

joshclose.github.io

以下にCsvHelperを使用したクラスマッピングの例を示します。
クラスマッピングには、CsvClassMap<T> を継承したクラスを用いて行います。

class PersonClassMap:CsvClassMap<Person>
{
    //コンストラクタにマッピング方法を記述
    public PersonClassMap()
    {
        Map(m => m.Name).Name("名前");
        Map(m => m.Birthday).Name("生年月日")
            .TypeConverterOption("yyyy/M/d");
        Map(m => m.Married).Name("配偶者")
            .TypeConverterOption(true, "あり")
            .TypeConverterOption(false, "なし");
        Map(m => m.Sex).Name("性別");    
    }
}

class MainWindowViewModel
{
    public void Read()
    {
        using (var baseReader = StreamReader("test.csv"))
        using (var reader = new CsvReader(baseReader))
        {
            //Class Map 登録
            reader.Configuration.RegisterClassMap<PersonClassMap>();

            Items = new ObservableCollection<Person>(reader.GetRecords<Person>());
        }
    }   
}

読み込むデータに応じて、クラスマッピングの定義を動的に行いたい

CsvHelperを使用することで、簡単にCSVデータを型付きのオブジェクトとして扱う事が可能となりました。
ただ、上記のような実装方法では「どのようなCSVデータを読んで、どんなクラスにマップするか」をアプリケーション実装時に知っている必要があります。

つまり、アプリケーションが対応しているフォーマットのCSVデータしか扱えない という事です。

できれば
CSVデータのフォーマットが変わる度にアプリケーションを変更したくない」ですし、「どのようなデータも柔軟に扱えるようにしたい」のです。

ならば、格納クラスの定義とマッピングの設定を外部から設定すればいいのでは? そこで「Roslyn Scripting」です。

C# スクリプトCSVエディタの設定を記述する

Roslyn Scripting

Roslyn Scripting を利用することで、C#で記述されたスクリプトをアプリケーション内で実行することが可能となります。

ufcpp.net

www.kekyo.net

NuGet でインストールして使用します。

PM > Install-Package Microsoft.CodeAnalysis.CSharp.Scripting

アプリケーション内でスクリプトを読み込んで実行する

スクリプトの実行方法は簡単で、基本的には以下の様にするだけです。

private CsvEditorCongifurationHost host = new CsvEditorCongifurationHost();

public async Task RunScriptAsync(string code)
{
    try
    {
         var script = CSharpScript.Create(code, ScriptOptions.Default
            .WithReferences(  //参照アセンブリを指定
                typeof(object).Assembly,
                typeof(CsvReader).Assembly)
            .WithImports(  //using する名前空間を指定
                "System",
                "System.Collections.Generic",
                "System.Linq",
                "System.Globalization",
                "System.Text",
                "CsvHelper.Configuration",
                "CsvHelper"),
         //RunAsync()に渡すglobalsオブジェクトの型を指定
         globalsType : typeof(ICsvEditorConfigurationHost));
         
         //RunAsync()にはスクリプト内で参照可能なオブジェクトを渡すことが可能
         scriptContext = await script.RunAsync(globals: host);
    }
    catch (CompilationErrorException e)
    {
        ErrorMessages.Add(e.Message + Environment.NewLine + e.Diagnostics);
    }
}

スクリプト側に公開するメソッドを定義する

アプリケーションとスクリプトとの間のやり取りは、Script.RunAsync() に渡すオブジェクト(以後「Hostオブジェクト」と呼びます)を使って行います。

  • Hostオブジェクトの型はCSharpScript.Create() の引数globalsTypeで指定できます。
    ここには、実際に渡すhostオブジェクトの型だけではなく、Hostオブジェクトの基底クラスや、インターフェースの型を指定しても良いです。

今回は、以下のようなインターフェースを定義して、CSVの読み込みとクラスマッピングの設定に必要なメソッド(およびプロパティ)のみを公開するようにします。

public interface ICsvEditorConfigurationHost
{
    //読み込むCSVテキストのエンコーディングを指定
    Encoding Encoding { get; set; }
    //クラスマッピングの設定(型指定のみ)
    void RegisterClassMap<T>();
    //クラスマッピングの設定(詳細設定あり)
    void RegisterClassMap<T>(Action<CsvClassMap<T>> propertyMapSetter);
    //読み込み設定
    void SetConfiguration(Action<CsvConfiguration> configurationSetter);
}

これを利用したスクリプト側のコードは以下の様になります。

//Encodingの指定。省略した場合は Encoding.Default が使われる
Encoding = Encoding.GetEncoding("shift-jis");

//データ格納用のクラスを定義
class Person
{
    public string Name { get; set; }
    public DateTime Birthday { get; set; }
    public bool Married { get; set; }
    public Sex Sex { get; set; }
}

enum Sex
{
    男性,
    女性
}

//クラスマッピングの詳細設定
//不要な場合は RegisterClassMap<Person>(); でOK
RegisterClassMap<Person>(classMap =>
{
    classMap.Map(m => m.Name).Name("名前");
    classMap.Map(m => m.Birthday).Name("生年月日")
        .TypeConverterOption("yyyy/M/d");
    classMap.Map(m => m.Married).Name("配偶者")
        .TypeConverterOption(true, "あり")
        .TypeConverterOption(false, "なし");
    classMap.Map(m => m.Sex).Name("性別");
});

//CsvHelperでの読み込み設定
SetConfiguration(conf =>
{
    //先頭に'#'がある行をコメントとしてスキップする
    conf.AllowComments = true;
    conf.Comment = '#';
    //...(必要に応じて設定内容を記述)
    
});

サンプルアプリケーション

サンプルコードをGithubに上げておきました。
スクリプトを実行した後の実際の読み取り処理などは、こちらのコードをご確認ください。

github.com

実行例

今回は動作確認のため、ウインドウの右側のテキストボックスに読み込んだスクリプトを表示して編集できるようにしました。
[Read]ボタンをクリックすると、スクリプトの実行とCSVファイルの読み取りを行います。

f:id:pierre3:20160627231205p:plain

[Read]ボタン押下後。読み込んだ結果がDataGridに表示されています。

f:id:pierre3:20160627231211p:plain

これだけでは、スクリプトで動的に実行されているかよくわからないので、少し手を加えてみます。
プロパティにAge を追加して生年月日から計算した値を格納するようにします。

class Person
{
    public string Name { get; set; }
    public DateTime Birthday { get; set; }
    public bool Married { get; set; }
    public Sex Sex { get; set; }
    public ushort Age { get; set; }
}

RegisterClassMap<Person>(classMap =>
{
    classMap.Map(m => m.Name).Name("名前");
    classMap.Map(m => m.Birthday).Name("生年月日")
        .TypeConverterOption("yyyy/M/d");
    classMap.Map(m => m.Married).Name("配偶者")
        .TypeConverterOption(true, "あり")
        .TypeConverterOption(false, "なし");
    classMap.Map(m => m.Sex).Name("性別");
    classMap.Map(m => m.Age).Name("年齢")
        .ConvertUsing(row => {
            var birthday = int.Parse(row.GetField<DateTime>(1).ToString("yyyyMMdd"));
            var today = int.Parse(DateTime.Today.ToString("yyyyMMdd"));
            return (today - birthday) /10000;
        });
});

このようにスクリプトを書き換えて再度[Read]ボタンをクリックします。

f:id:pierre3:20160627232245p:plain

ちゃんと”年齢”カラムが追加されて計算で求めた年齢が入っています。

一覧のフィルタ、ソートをスクリプトで実行してみる

ついでに、表示する一覧をLINQで操作できるようにしてみます。
HostオブジェクトのインターフェースにQuery<T>()メソッドを追加します。

public interface ICsvEditorConfigurationHost
{
    void Query<T>(Func<IEnumerable<T>, IEnumerable<T>> query);
}

テキストボックスを1つ追加して、そこに以下のコードを記述します。

Query<Person>( source => source
    .Where(m => m.Sex == Sex.男性)
    .OrderBy(m => m.Married)
    .ThenBy(m=> m.Age));

f:id:pierre3:20160627233940p:plain

書いた通りにフィルタ、ソートが実行されて表示されました。

編集と保存

セルを編集して保存することも可能です。

f:id:pierre3:20160627234646p:plain

[Write]ボタンをクリックでDataGridの現在の状態がCSV形式で出力されます。

(出力結果)

名前,生年月日,配偶者,性別,年齢
小林,2005/1/2,False,男性,11
鈴木,2001/8/10,False,男性,14
田中,1990/11/20,False,男性,25
木村,1989/11/6,False,男性,26
佐藤,1986/9/2,False,男性,29
伊藤,1986/10/10,False,男性,29
りんな,1998/8/3,False,女性,17
林,1999/10/2,True,男性,16
吉田,1986/9/12,True,男性,29
清水,1980/9/7,True,男性,35
松本,1980/2/2,True,男性,36
加藤,1955/6/10,True,男性,61

CSV形式での出力も CsvHelperを使用しています。出力時もClassMapが使用可能で、今回は読み込み時と同じものを使用しました。
(※ 配偶者 「あり/なし」が「True/False」に変わってしまっているのは想定外でした。bool の変換は読み込み時のみ有効なのだろうか?)

まとめ

ひとまず、クラスマッピングの定義をC#スクリプトで記述して、DataGridにバインドする(型付けされた)オブジェクトを動的に生成することが出来ました。

次は、文字列の表示フォーマットと、セル編集時のバリデーションの設定辺りを検討してみたいと思います。

[Reactive Extensions]お題:5人揃ってゴレンジャイ!

最近 Reactive Extensions に入門した同僚に出したお題です。
ちょっとしたお遊びのつもりで出題したのですが、意外と楽しんでもらえたようです。

目次

お題

Reactive Extensionsを使って、次の処理を実装してください。

  1. ゴレンジャイのメンバー、「赤レンジャイ」「青レンジャイ」「黄レンジャイ」「緑レンジャイ」「桃レンジャイ」からランダムに1人選び、表示します。
  2. それを5回繰り返して、1人も重複していなければ成功です。「5人揃ってゴレンジャイ!」と表示して終了します。
  3. メンバーが重複した時点で失敗とします。1人目からやり直してください。
  4. 成功するまで繰り返し行います。

(元ネタはこちら)


ガキの使い ゴレンジャイゲーム

私の回答

やり方はいろいろあると思いますが、今回は「成功するまで繰り返す」部分でRetry()を使おう、と決めて全体の実装を考えました。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Linq;


class Program
{
  static void Main(string[] args)
  {
      var goren = new[]
      { 
        "赤レンジャイ! ",
        "青レンジャイ! ",
        "黄レンジャイ! ",
        "緑レンジャイ! ",
        "桃レンジャイ! " 
      };
      Observable.Repeat(new Random(), 5)
        .Select(r => goren[r.Next(5)])
        .Scan(new { Value = "", IsOK = true, Buffer = new HashSet<string>() },
          (a, x) =>
          {
            var IsOK = a.Buffer.Add(x);
            if (!IsOK) { a.Buffer.Clear(); }
            return new {
              Value = x,
              IsOK,
              Buffer = a.Buffer
            };
          })   
        .SelectMany(a => a.IsOK
          ? Observable.Return(a.Value + " ")
          : Observable.Return(a.Value + " 被っとるやないかい!\n")
                      .Concat(Observable.Throw<string>(new Exception())))
        .Retry()
        .Subscribe(
          onNext: x => Console.Write(x),
          onCompleted: () => Console.WriteLine("5人揃ってゴレンジャイ!!"));
  }
}

実行結果

青レンジャイ!  青レンジャイ!  被っとるやないかい!
桃レンジャイ!  黄レンジャイ!  青レンジャイ!  桃レンジャイ!  被っとるやないかい!
赤レンジャイ!  桃レンジャイ!  黄レンジャイ!  黄レンジャイ!  被っとるやないかい!
桃レンジャイ!  桃レンジャイ!  被っとるやないかい!
黄レンジャイ!  赤レンジャイ!  赤レンジャイ!  被っとるやないかい!
… (中略)…
青レンジャイ!  赤レンジャイ!  緑レンジャイ!  青レンジャイ!  被っとるやないかい!
青レンジャイ!  赤レンジャイ!  黄レンジャイ!  緑レンジャイ!  青レンジャイ!  被っ とるやないかい!
桃レンジャイ!  赤レンジャイ!  緑レンジャイ!  黄レンジャイ!  緑レンジャイ!  被っ とるやないかい!
赤レンジャイ!  桃レンジャイ!  黄レンジャイ!  桃レンジャイ!  被っとるやないかい!
青レンジャイ!  緑レンジャイ!  赤レンジャイ!  青レンジャイ!  被っとるやないかい!
赤レンジャイ!  緑レンジャイ!  青レンジャイ!  黄レンジャイ!  桃レンジャイ!  5人揃ってゴレンジャイ!!

解説

  • Repeat() + Select() => ランダムに5人選ぶ
  • Scan() => 重複チェックのために値をまとめる
  • SelectMany() => 重複していたら 例外を流す
  • Retry() => 例外が流れて来たら、やり直し
  • Subscribe()-onNext => 流れてきた値を表示する。
  • Subscribe()-onCompleted => 無事に5人分流れて来たら完了!「5人揃ってゴレンジャイ!」

重複判定

Scan()のところが少々分かりずらいでしょうか。
ここでは、毎回流れてくる値を、以下をメンバに持つ匿名型にまとめています。

  • string Value: 現在の値
  • bool IsOK : 重複した(false)/していない(true)
  • HashSet<String> Buffer: 値をためておくバッファ(重複判定に使用)

HashSet<T> は重複した値を追加できないため、既に存在する要素を追加しようとすると、追加用メソッドAdd() は失敗しfalse を返してきます。
これを利用して「かぶった」/「かぶっていない」の判定を行っています。

var IsOK = Buffer.Add(x);

失敗したら例外を流す

Retry()は、Rxのストリームに例外が流れてきた場合に動作します。

失敗したら例外を発生させれば良いのですが、ストリーム上に流すためにはObservable.Throw()メソッドを使用してIObservable<T> にラップしてやる必要があります。

ここで、流れる値が IObservable<T>、(つまり IObservable<IObservable<T>>) となるので、通常の値も Observable.Return() を使用してIObservable<T> で流してやる必要があります。
そして、IObservable<IObservable<T>>IObservable<T> に戻すために SelectMany() が使われています。

.SelectMany(a => a.IsOK
  ? Observable.Return(a.Value + " ")
  : Observable.Return(a.Value + " 被っとるやないかい!\n")
              .Concat(Observable.Throw<string>(new Exception())))

(失敗時のメンバーも表示したいので、失敗時の値 と 例外 をConcat()でつなげて流すようにしています。 )

蛇足

失敗したら例外を流す処理 (SelectMany()の部分) は、拡張メソッドに切り出してThrowIfFalse() みたいにしたらもっと分かりやすくなりそうですね。他のケースでも使えそうな気もします。

ThrowIfFalse() 拡張メソッド

という事で、作ってみました。

ThrowIfFalse 条件式がFalseを返したら例外を発行するIObservable<T> 拡張メソッド · GitHub

回答のコードを置き換えてみます。
書いている内容はほとんど変わっていませんが、やりたい事の意図がより伝わりやすくなったと思います。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Linq;


class Program
{
  static void Main(string[] args)
  {
      var goren = new[]
      { 
        "赤レンジャイ! ",
        "青レンジャイ! ",
        "黄レンジャイ! ",
        "緑レンジャイ! ",
        "桃レンジャイ! " 
      };
      Observable.Repeat(new Random(), 5)
        .Select(r => goren[r.Next(5)])
        .Scan(new { Value = "", IsOK = true, Buffer = new HashSet<string>() },
          (a, x) =>
          {
            var IsOK = a.Buffer.Add(x);
            if (!IsOK) { a.Buffer.Clear(); }
            return new {
              Value = x,
              IsOK,
              Buffer = a.Buffer
            };
          })   
        .ThrowIfFalse(a => a.IsOK, new Exception(),
          resultSelectorWhenTrue : a => a.Value + " ",
          resultSelectorWhenFalse : a => a.Value + " 被っとるやないかい!\n")
        .Retry()
        .Subscribe(
          onNext: x => Console.Write(x),
          onCompleted: () => Console.WriteLine("5人揃ってゴレンジャイ!!"));
  }
}