C# データレコード形式のテストデータを生成するライブラリを公開しました。

## このドキュメントの内容

データレコード形式のテストデータを生成するライブラリ `mxProject.Devs.DataGenerator` を紹介します。

## ライブラリの概要

* データレコード形式のテストデータを生成するための .NET Standard 2.0 ライブラリです。

* IDataReader インターフェースを介してデータレコードにアクセスできます。`Dapper` を用いたO/Rマッピングや `Reactive Extensions` を用いた Observable モデルの実装も容易だと思います。

* IDataReader.Read メソッドが呼び出された時点で次のデータレコードを生成します。データジェネレーター内部ではなるべくリストを保持しないように設計しています。生成するデータレコードの数が多い場合でも、データ生成に必要となるリソースはそれほど大きくなりません。

### 公開先

GitHub
https://github.com/mxProject/DataGenerator/tree/main/mxProject.Devs.DataGenerator

Nuget
https://www.nuget.org/packages/mxProject.Devs.DataGenerator/


## サンプルコード

### ビルダーを用いた実装例

```c#
// ビルダーを定義します。
DataGeneratorBuilder builder = new DataGeneratorBuilder()

    // 1 から 100 までの連番を返すフィールド
    .AddField(factory => factory.SequenceInt32(
        "ID",
        1,
        100
        ))

    // 今日から一か月後までの間のランダムな日付を返すフィールド
    .AddField(factory => factory.RandomDateTime(
        "SalesDate",
        DateTime.Today,
        DateTime.Today.AddMonths(1),
        x => x.Date
        ))

    // 1, 2, 3 を順番に返すフィールド
    .AddField(factory => factory.Each(
        "ShopCode",
        new int[] { 1, 2, 3 }
        ))

    // 0 から 100000 までの間のランダムな数値を返すフィールド
    .AddField(factory => factory.RandomInt64(
        "Sales",
        minValue: 0,
        maxValue: 100000
        ))

    // "SalesDate" フィールドの値に対応する曜日を返すフィールド
    .AddAdditionalField(
        "DayOfWeek",
        typeof(DayOfWeek),
        rec => rec.GetDateTime("SalesDate").DayOfWeek
        )

    // "ShopCode" フィールドの値に対応する複数の付加列の値を返すフィールド
    .AddJoinField(factory => factory.WithDictionary(
        "ShopCode",
        additionalFieldNames: new[]
        {
            "ShopName",
            "OpeningTime"
        },
        additionalValues: new Dictionary<int, (StringValue, TimeSpan)>()
        {
            { 1, ("SHOP1", new TimeSpan(10, 0, 0)) },
            { 2, ("SHOP2", new TimeSpan(18, 0, 0)) }
        }
        ))
    ;

// 10件のデータレコードを生成するデータジェネレーターを生成し、IDataReader インスタンスとして返します。
using IDataReader reader = await builder.BuildAsDataReaderAsync(generateCount: 10);

// IDataReader からデータレコードを取得して出力します。
for (int i = 0; i < reader.FieldCount; ++i)
{
    if (i > 0) { Debug.Write('\t'); }
    Debug.Write(reader.GetName(i));
}
Debug.WriteLine("");

while (reader.Read())
{
    for (int i = 0; i < reader.FieldCount; ++i)
    {
        if (i > 0) { Debug.Write('\t'); }
        Debug.Write(reader.GetValue(i));
    }
    Debug.WriteLine("");
}
```

次のようなデータが出力されます。

```console
ID  SalesDate            ShopCode  Sales  DayOfWeek  ShopName  OpeningTime
1   2021/10/05 0:00:00   1         15702  Tuesday    SHOP1     10:00:00
2   2021/09/20 0:00:00   2         99983  Monday     SHOP2     18:00:00
3   2021/09/27 0:00:00   3         16640  Monday
4   2021/09/24 0:00:00   1         12485  Friday     SHOP1     10:00:00
5   2021/10/02 0:00:00   2         18911  Saturday   SHOP2     18:00:00
6   2021/10/17 0:00:00   3         79244  Sunday
7   2021/10/17 0:00:00   1         38010  Sunday     SHOP1     10:00:00
8   2021/09/28 0:00:00   2         49279  Tuesday    SHOP2     18:00:00
9   2021/09/24 0:00:00   3         43640  Friday
10  2021/10/15 0:00:00   1         71709  Friday     SHOP1     10:00:00
```

### 設定値クラスを用いた実装例

設定値クラスを用いてデータジェネレーターを定義することもできます。これらの設定値クラスはJsonシリアライズをサポートしています。
次のコードは前述のサンプルコードとほぼ同じ内容を実装しています。

```c#
// ジェネレーターを定義します。
DataGeneratorSettings generatorSettings = new DataGeneratorSettings()
{
    Fields = new DataGeneratorFieldSettings[]
    {
        // 1 から 100 までの連番を返すフィールド
        new SequenceInt32FieldSettings()
        {
            FieldName = "ID",
            InitialValue = 1,
            MaximumValue = 100
        },

        // 今日から一か月後までの間のランダムな日付を返すフィールド
        new RandomDateTimeFieldSettings()
        {
            FieldName = "SalesDate",
            MinimumValue = DateTime.Today,
            MaximumValue = DateTime.Today.AddMonths(1),
            SelectorExpression = "x => x.Date"
        },

        // 1, 2, 3 を順番に返すフィールド
        new EachFieldSettings<int>()
        {
            FieldName = "ShopCode",
            Values = new int?[]{ 1, 2, 3 }
        },

        // 0 から 100000 までの間のランダムな数値を返すフィールド
        new RandomDecimalFieldSettings()
        {
            FieldName = "Sales",
            MinimumValue = 1,
            MaximumValue = 100000
        }
    },
    AdditionalFields = new DataGeneratorAdditionalFieldSettings[]
    {
        // "SalesDate" フィールドの値に対応する曜日を返すフィールド
        new ExpressionFieldSettings()
        {
            FieldName = "DayOfWeek",
            ValueType = typeof(DayOfWeek).FullName,
            Expression = "rec => rec.GetDateTime(\"SalesDate\").DayOfWeek"
        }
    },
    AdditionalTupleFields = new DataGeneratorAdditionalTupleFieldSettings[]
    {
        // "ShopCode" フィールドの値に対応する複数の付加列の値を返すフィールド
        new JoinFieldSettings()
        {
            KeyFields = new DataGeneratorFieldInfo[]
            {
                new DataGeneratorFieldInfo("ShopCode", typeof(int))
            },
            AdditionalFields = new DataGeneratorFieldInfo[]
            {
                new DataGeneratorFieldInfo("ShopName", typeof(string)),
                new DataGeneratorFieldInfo("OpeningTime", typeof(TimeSpan))
            },
            AdditionalValues = new Dictionary<string?[], string?[]>()
            {
                {
                    new[] { "1" },
                    new[] { "SHOP1", "10:00:00" }
                },
                {
                    new[] { "2" },
                    new[] { "SHOP2", "18:00:00" }
                }
            }
        }
    }
};

// ビルダーを生成します。
DataGeneratorContext context = new DataGeneratorContext();
DataGeneratorBuilder builder = generatorSettings.CreateBuilder(context);

// 10件のデータレコードを生成するデータジェネレーターを生成し、IDataReader インスタンスとして返します。
using IDataReader reader = await builder.BuildAsDataReaderAsync(generateCount: 10);

// IDataReader からデータレコードを取得して出力します。
for (int i = 0; i < reader.FieldCount; ++i)
{
    if (i > 0) { Debug.Write('\t'); }
    Debug.Write(reader.GetName(i));
}
Debug.WriteLine("");

while (reader.Read())
{
    for (int i = 0; i < reader.FieldCount; ++i)
    {
        if (i > 0) { Debug.Write('\t'); }
        Debug.Write(reader.GetValue(i));
    }
    Debug.WriteLine("");
}

// 設定値インスタンスをJSON文字列にシリアライズします。
var converterBuilder = DataGeneratorFieldTypeConverterBuilder.CreateDefault();

var jsonSettings = new JsonSerializerSettings
{
    Formatting = Formatting.Indented
};

foreach (var converter in converterBuilder.Build())
{
    jsonSettings.Converters.Add(converter);
}

string json = JsonConvert.SerializeObject(generatorSettings, jsonSettings);
Debug.WriteLine(json);
```

次のJSON文字列は、前述の generatorSettings をシリアライズした結果です。

```json
{
  "Fields": [
    {
      "Name": "ID",
      "NullProb": 0.0,
      "Initial": 1,
      "Max": 100,
      "Increment": null,
      "FieldType": "SequenceInt32"
    },
    {
      "Name": "SalesDate",
      "NullProb": 0.0,
      "Min": "2021-09-23T00:00:00+09:00",
      "Max": "2021-10-23T00:00:00+09:00",
      "Selector": "x => x.Date",
      "FieldType": "RandomDateTime"
    },
    {
      "Name": "ShopCode",
      "NullProb": 0.0,
      "Values": [
        1,
        2,
        3
      ],
      "FieldType": "EachInt32"
    },
    {
      "Name": "Sales",
      "NullProb": 0.0,
      "Min": 1.0,
      "Max": 100000.0,
      "Selector": null,
      "FieldType": "RandomDecimal"
    }
  ],
  "TupleFields": null,
  "AdditionalFields": [
    {
      "FieldName": "DayOfWeek",
      "ValueType": "System.DayOfWeek",
      "Expression": "rec => rec.GetDateTime(\"SalesDate\").DayOfWeek",
      "FieldType": "Expression"
    }
  ],
  "AdditionalTupleFields": [
    {
      "AdditionalFields": [
        {
          "FieldName": "ShopName",
          "ValueType": "System.String"
        },
        {
          "FieldName": "OpeningTime",
          "ValueType": "System.TimeSpan"
        }
      ],
      "KeyFields": [
        {
          "FieldName": "ShopCode",
          "ValueType": "System.Int32"
        }
      ],
      "AdditionalValues": [
        {
          "Key": [
            "1"
          ],
          "Value": [
            "SHOP1",
            "10:00:00"
          ]
        },
        {
          "Key": [
            "2"
          ],
          "Value": [
            "SHOP2",
            "18:00:00"
          ]
        }
      ],
      "FieldType": "Join"
    }
  ]
}
```

## 機能説明

* 次の手順に従ってデータを生成します。

  1. データレコードを構成するフィールドの名前とデータ生成アルゴリズムを定義します。
  2. 生成するデータ数を指定して DataGenerator クラスのインスタンスを生成します。IDataReader インターフェースのインスタンスを生成することもできます。
  3. DataGenerator インスタンスの GenerateNext メソッドを呼び出します。データレコードはこのとき生成されます。（IDataReader インスタンスの場合は Read メソッド）
  4. GenerateNext メソッドの戻り値が false になるまで繰り返します。（IDataReader インスタンスの場合は Read メソッド）

### データフィールド

* DataGenerator に定義できるフィールドは、次の四つの種類に分類することができます。

|Field Type|Summary|
|:--|:--|
|DataGeneratorField|一つのフィールドの値を生成します。|
|DataGeneratorTupleField|複数のフィールドの値の組み合わせを生成します。|
|DataGeneratorAdditionalField|生成された値をもとに追加の一つのフィールドの値を返します。|
|DataGeneratorAdditionalTupleField|生成された値をもとに追加の複数のフィールドの値を返します。|

#### DataGeneratorField

|Field Type|Summary|
|:--|:--|
|Any|指定された値の中から何れかの値を返します。|
|Computing|指定された式の計算結果を返します。|
|DbQuery|指定されたデータベースクエリーから一つのフィールドの値を読み取って返します。|
|Each|指定された値を順番に返します。|
|FormattedString|指定された書式文字列でフォーマットされた文字列値を返します。|
|Random|指定された範囲内のランダムな値を生成します。|
|Sequence|指定された範囲内のシーケンシャルな値を生成します。|

#### DataGeneratorTupleField

|Field Type|Summary|
|:--|:--|
|DbQuery|指定されたデータベースクエリーから複数のフィールドの値を読み取って返します。|
|DirectProduct|指定された複数のフィールドの値を生成し、それらの直積を返します。|
|EachTuple|指定された複数のフィールドの値の組み合わせを順番に返します。|

#### DataGeneratorAdditionalField

|Field Type|Summary|
|:--|:--|
|Expression|生成されたデータレコードを引数として受け取り、指定された式の戻り値を返します。|

#### DataGeneratorAdditionalTupleField

|Field Type|Summary|
|:--|:--|
|Join|生成されたデータレコードを引数として受け取り、キーとするフィールドの値に対応する値をディクショナリまたはルックアップから取得して返します。|
|JoinDbQuery|生成されたデータレコードを引数として受け取り、キーとするフィールドの値に対応する値をデータクエリーから取得して返します。|

## まとめ

テストデータを作成したり、ユニットテストに対応するテストデータの内容が不意に変更されないように維持したり、テストデータにまつわる苦労は少しでも減らしたいものです。テストデータを効率的に作成できるかどうかは、テストの精度を大きく左右すると考えています。
今後はこのライブラリを利用したモックやテストデータ生成ツールを考えてみたいと思います。