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ファイルと合わせることが出来ました。
あとは、カラム毎に「数値の入力可能範囲」や、「入力可能な文字」等の細かなルールを指定できると良いですよね。

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