Windows Forms C#定石 - DataGridView - DataTable

## はじめに

C# ソフト開発時に、決まり事として実施していた内容を記載します。

**DataGridView については下記記事もあります**
- [Windows Forms C#定石 - DataGridView - EditMode, DropDown](https://qiita.com/chai0917/items/55af57dc17de0dd97bf0)
- [Windows Forms C#定石 - DataGridView - ReadOnly, Disable相当](https://qiita.com/chai0917/items/525634fb35cf4efc0b40)
- [Windows Forms C#定石 - DataGridView - 値依存イメージ表示, ちらつき防止](https://qiita.com/chai0917/items/608f511b6609a8713526)


### テスト環境

ここに記載した情報/ソースコードは、Visual Studio Community 2022 を利用した下記プロジェクトで生成したモジュールを Windows 11 24H2 で動作確認しています。
- Windows Forms - .NET Framework 4.8
- Windows Forms - .NET 8

::: note
記載したソースコードは .NET 8 ベースとしています。
.NET Framework 4.8 の場合は、コメントで記載している null 許容参照型の明示 `?` を削除してください。
:::

::: note
Visual Studio 2022 - .NET Framework 4.8 は、C# 7.3 が既定です。
このため、サンプルコードは、C# 7.3 機能範囲で記述しています。
:::


## DataTable

DataGridView に DataTable をバインドするサンプルとして、SQL Server のテーブルを一覧操作するコードを記載しようと思います。

https://learn.microsoft.com/ja-jp/dotnet/api/system.data.datatable

### DataRow

https://learn.microsoft.com/ja-jp/dotnet/api/system.data.datarow

DataRow は DataTable 内のデータ行です。
DataRow.RowStatus で、AcceptChanges（RowStatusのクリア）実施後、行に対する操作を確認できます。

| DataRowState | 状態                                         |
| ------------ | -------------------------------------------- |
| Added        | 行追加された                                  |
| Deleted      | 行削除された                                  |
| Modified     | 行データが更新された                           |
| Unchanged    | 変更されていない                               |
| Detached     | DataRow を新規作成して、DataTable に未追加の状態 |


### SQL Server

SQL Server 2022 Express をインスタンス名：Hoge、SQL Server 認証モードでインストールして、[AdventureWorks サンプル データベース - SQL Server](https://learn.microsoft.com/ja-jp/sql/samples/adventureworks-install-configure) を導入します。
サンプルとして手頃なテーブルが見つけられなかったので、[Northwindサンプル](https://github.com/Microsoft/sql-server-samples/tree/master/samples/databases/northwind-pubs) の Shippers（ShipperID の IDENTITY 指定は除外）を作成することにします。

```SQL
USE AdventureWorks2022
GO
CREATE SCHEMA Northwind
GO
CREATE TABLE Northwind.Shippers (
  [ShipperID] int NOT NULL ,
  [CompanyName] nvarchar (40) NOT NULL ,
  [Phone] nvarchar (24) NOT NULL ,
  CONSTRAINT [PK_Shippers] PRIMARY KEY
  (
    [ShipperID]
  )
)
GO
INSERT INTO Northwind.Shippers ([ShipperID],[CompanyName],[Phone])
  VALUES(1,'Speedy Express','(503) 555-9831')
INSERT INTO Northwind.Shippers ([ShipperID],[CompanyName],[Phone])
  VALUES(2,'United Package','(503) 555-3199')
INSERT INTO Northwind.Shippers ([ShipperID],[CompanyName],[Phone])
  VALUES(3,'Federal Shipping','(503) 555-9931')
GO
```

SQL Server アクセスを行うので [NuGet Gallery | Microsoft.Data.SqlClient](https://www.nuget.org/packages/microsoft.data.sqlclient) を導入します。

```
PM> NuGet\Install-Package Microsoft.Data.SqlClient
```
```CSharp
using System.Data;
using Microsoft.Data.SqlClient;
```

Sql Server で DataTable ならば、SqlDataAdapter 利用が便利です。

https://learn.microsoft.com/ja-jp/dotnet/api/system.data.sqlclient.sqldataadapter

SQL Server からのデータ取得は SqlDataAdapter.Fill を利用しますが、データ更新（削除/追加を含む）は、SqlDataUdapter.Update は利用せず、削除、追加、更新の順でそれぞれを実行する形態とします。

::: note
SqlDataUdapter.Update を利用しない理由

- 理由１
  - 後述「メインフォーム - データ更新」に記載した、更新競合に対する楽観的排他制御など細やかな処理を行うことを想定
- 理由２
  - 以下のように、削除と追加が存在する場合、どのような順序で処理されるかを理解しきれていないので、利用することを躊躇している
    - Primary Key である ShipperID=1 の行が存在していて、それを削除
    - ShipperID=1 の行を追加
    - ShipperID=1 の行を削除
    - ShipperID=1 の行を追加
:::


### 構成

DataGridView 上での行追加、行追加は操作ミスがあり得るので、それぞれの操作用のボタンと、SQL Server への更新用ボタンを用意します。

![SQLServer-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/b63d51d9-41dc-4c53-8116-38e67f33d257.png)

| コントール    | 名称          |  用途   |
| ------------ | ------------- | ------ |
| DataGridView | dataGridView1 |        |
| Button       | btnAppend     | 行追加  |
| Button       | btnDelete     | 行削除  |
| Button       | btnUpdate     | 更新    |

::: note
本サンプルでは、行データの更新は、DataGirdView 上で直接編集としていますが、行追加と同様にサブフォームで行う実装も考えられます。
この場合、DataGridView 列定義で全ての列を ReadOnly として、セルクリック もしくは Enter キーで、更新用サブフォーム（ ShipperID を ReadOnly ）表示などの実装が考えられます。

このように、行追加/行削除/行編集を DataGridView 上で直接実施しない形態とした場合、後述「メインフォーム - データ更新」に記載した更新競合を考えると、本サンプルのように一括更新ではなく、行追加/行削除/行編集の都度、SQL Server に対して更新（追加/削除を含む）するという選択もあります。
:::

行追加はサブフォームを用意して、サブフォーム入力値で更新します。

![SQLServer-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/065442d2-abd8-4642-b2ea-018301472120.png)


| コントール    | 名称           |  用途   |
| ------------ | -------------- | ------ |
| TextBox      | txtShipperID   | ShipperID 入力 |
| TextBox      | txtCompanyName | CompanyName 入力 |
| TextBox      | txtPhone       | Phone 入力 |
| Button       | btnUpdate      | 更新       |
| Button       | btnCancel      | キャンセル  |


## サンプルコード

### メインフォーム - データ取得

削除行は、DataRow.RowState = DataRowState.Deleted となりますが、項目データをアクセスできないので、削除した ShipperID を管理する List<int> を用意します。

```CSharp
private List<int> lstDelete = new List<int>();
```

メインフォーム（Form1）コンストラクタで、DateGridView プロパティ設定を行います。
列定義では DataPropertyName に DataTable の項目名をセットします。
また、ShipperID は Primary Key なので ReadOnly とします。

```CSharp
public Form1()
{
  InitializeComponent();

  // デザイナで DataGridView dataGridView1 を配置
  dataGridView1.AutoGenerateColumns = false;
  dataGridView1.SelectionMode = DataGridViewSelectionMode.FullRowSelect;  // 行選択モード
  dataGridView1.MultiSelect = false;             // 複数選択無効
  dataGridView1.ColumnHeadersVisible = true;     // 列ヘッダ表示
  dataGridView1.RowHeadersVisible = false;       // 行ヘッダ非表示
  dataGridView1.AllowUserToDeleteRows = false;   // 削除キーで削除を無効
  dataGridView1.AllowUserToAddRows = false;      // 末尾行での行追加を無効
  dataGridView1.ScrollBars = ScrollBars.Both;
  dataGridView1.EditMode = DataGridViewEditMode.EditOnEnter;

  dataGridView1.Columns.AddRange(new DataGridViewColumn[]
  {
    new DataGridViewTextBoxColumn { Name = "ShipperID", 
      DataPropertyName = "ShipperID", Width = 100, ReadOnly = true },
    new DataGridViewTextBoxColumn { Name = "CompanyName", 
      DataPropertyName = "CompanyName", Width = 200 },
    new DataGridViewTextBoxColumn { Name = "Phone", 
      DataPropertyName = "Phone", Width = 200 }
  });
}
```

SQL Server 接続文字列を SqlConnectionStringBuilder で作成します。

https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.data.sqlclient.sqlconnectionstringbuilder

```CSharp
// 接続文字列：const string でも良いが、SqlConnectionStringBuilder 利用
private string GetConnectionString()
{
  var builder = new Microsoft.Data.SqlClient.SqlConnectionStringBuilder();
  builder.DataSource = "(local)\\Hoge";          // 自マシン、インスタンス:Hoge
  builder.InitialCatalog = "AdventureWorks2022"; // データベース
  builder.IntegratedSecurity = false;            // SQL Server 認証
  builder.UserID = "sa";
  builder.Password = "$$PASSWORD";               // TODO
  builder.Encrypt = false;
  builder.CommandTimeout = 10;                   // 10秒
  return builder.ToString();
}
```

SQL Server からのデータ取得は、SqlDataAdapter.Fill を利用して、SELECT 結果を DataTable にセットします。
SqlDataAdapter.Fill は、コネクションが close の状態で呼ばれた時は、コネクションを open して、データを取得後に close します。 

DataGridView.DataSource に直接 DataTable をセットすることも可能ですが、BindingSource を経由して DataTable をセットすると、複数の操作（フィルタリング、ソート、選択行の追跡、イベント処理）でメリットがあるので、この形態とします。

https://learn.microsoft.com/ja-jp/dotnet/desktop/winforms/controls/sort-and-filter-ado-net-data-with-wf-bindingsource-component

```CSharp
// SQL Server から DataTable 生成して dataGridVieww1 にバインド
private bool DataLoad()
{
  bool bResult = false;

  // 削除行データをクリア
  lstDelete.Clear();

  // SQL Server から取得
  try
  {
    var queryString = "SELECT * FROM Northwind.Shippers";

    using (var connection = new Microsoft.Data.SqlClient.SqlConnection(GetConnectionString()))
    using (var command = new Microsoft.Data.SqlClient.SqlCommand(queryString, connection))
    using (var adapter = new Microsoft.Data.SqlClient.SqlDataAdapter())
    {
      if (adapter != null)
      {
        adapter.SelectCommand = command;
        var table = new DataTable();
        adapter.Fill(table);                          // DataTable に取得
        table.AcceptChanges();                        // RowState クリア
        dataGridView1.DataSource = new BindingSource  // データバインド
        {
          DataSource = table
        };
        bResult = true;
      }
    }
  }
  catch (Exception)
  {
    // TODO - ERROR
  }
  return bResult;
}
```

メインフォーム Shown イベントなどで、上記 DataLoad を呼び出すと、DataGridView に一覧表示されます。

ShipperID は Primary Key なので、重複判断を行うメソッドを用意します。

```CSharp
// メインフォーム：ShipperID 重複チェック
public bool IsDupulicateId(int shipperId)
{
  // DataGridView は BindingSource でフィルタリング結果表示などの可能性があるので
  // バインドしている DataTable を直接参照
  if (dataGridView1.DataSource is BindingSource bindingSource
   && bindingSource.List is DataView boundView
   && boundView.Table is DataTable dataTable)
  {
    foreach (DataRow row in dataTable.Rows)
    {
      // 削除された行のデータをアクセスすると Exception になる
      if (row.RowState != DataRowState.Deleted)
      {
        if (row["ShipperID"] is int id)
        {
          if (shipperId == id)
          {
            // 対象 ID は存在
            return true;
          }
        }
      }
    }
  }
  return false;
}
```

### サブフォーム

行追加用のサブフォーム（DlgAppend.cs）は、更新処理で ShipperID 重複チェックを行い、問題なければ、プロパティに各項目の情報をセットします。
CompanyName については、フォーム プロパティに CompanyName が存在するので、ShipperName というプロパティとします。

```CSharp
// サブフォーム
public partial class DlgAppend : Form
{
  public int ShipperID { get; set; } = -1;
  public string ShipperName { get; set; } = string.Empty;
  public string Phone { get; set; } = string.Empty;

  public DlgAppend()
  {
    InitializeComponent();
  }
  // .NET Framework 時 object? の ? 不要
  private void btnUpdate_Click(object? sender, EventArgs e)
  {
    int shipperId;
    if (int.TryParse(txtShipperID.Text.Trim(), out shipperId))
    {
      if(this.Owner is Form1 form)
      {
        if (shipperId <= 0 || form.IsDupulicateId(shipperId))
        {
          MessageBox.Show("ShipperID が不正、もくしは、重複しています", "ERROR",
                          MessageBoxButtons.OK, MessageBoxIcon.Error);
          return;
        }
        ShipperID = shipperId;
        ShipperName = txtCompanyName.Text.Trim();
        Phone = txtPhone.Text.Trim();
        DialogResult = DialogResult.OK;
        this.Close();
        return;
      }
    }
    MessageBox.Show("入力値が正しくありません", "ERROR", 
                    MessageBoxButtons.OK, MessageBoxIcon.Error);
  }
  // .NET Framework 時 object? の ? 不要
  private void btnCancel_Click(object? sender, EventArgs e)
  {
    DialogResult = DialogResult.Cancel;
    this.Close();
  }
}
```

### メインフォーム - 行追加/行削除

DataGridView に対する行追加、行削除を実装します。
下記サンプルコードには記載していませんが、必要に応じて、行追加/行削除 実施前に MessageBox で確認してください。

```CSharp
// DataGridView：行追加 - .NET Framework 時 object? の ? 不要
private void btnAppend_Click(object? sender, EventArgs e)
{
  // サブフォーム表示
  using (var dlg = new DlgAppend())
  {
    dlg.Owner = this;
    var result = dlg.ShowDialog();
    if (result == DialogResult.OK)
    {
      // DataGridView ではなく、バインドしている DataTable を直接参照
      if (dataGridView1.DataSource is BindingSource bindingSource
       && bindingSource.List is DataView boundView
       && boundView.Table is DataTable dataTable)
      {
        // 行追加
        dataTable.Rows.Add(dlg.ShipperID, dlg.ShipperName, dlg.Phone);
      }
    }
  }
}
// DataGridView：行削除 - .NET Framework 時 object? の ? 不要
private void buttonDelete_Click(object? sender, EventArgs e)
{
  if (dataGridView1.CurrentRow != null)
  {
    // DataGridView ではなく、バインドしている DataTable を直接参照
    if (dataGridView1.CurrentRow.DataBoundItem is DataRowView boundRow
     && boundRow.Row["ShipperID"] is int id)
    {
      // 行削除
      boundRow.Row.Delete();
      // DataRow.RowState = DataRowState.Deleted のデータは参照不可なので id を記録
      lstDelete.Add(id);
    }
  }
}
```

### メインフォーム - データ更新

SQL Server への更新処理を実装します。
- DELETE は `List<int> lstDelete` 参照
- INSERT、UPDATE は DataTable 参照

::: note
SQL Server 更新については、更新競合に対する何らかの対策が必要ですが、DataGridView での DataTable 利用が主題の記事なので、割愛させて頂きます。

更新競合に対する対策
・[データベースの楽観ロックと悲観ロックを理解する](https://zenn.dev/farstep/articles/optimistic-and-pessimistic-locking-in-database)
・[Webアプリケーション開発における、楽観的排他制御・悲観的排他制御のまとめ](https://qiita.com/ison12/items/0f1bd5aae5e1f145f508)
:::

```CSharp
// DataGridView：更新 - .NET Framework 時 object? の ? 不要
private void btnUpdate_Click(object? sender, EventArgs e)
{
  bool bCommit = false;
  // 削除の重複削除
  if (lstDelete.Count > 0)
  {
    lstDelete = lstDelete.Distinct().ToList();
  }
  // DataGridView ではなく、バインドしている DataTable を直接参照
  if (dataGridView1.DataSource is BindingSource bindingSource
   && bindingSource.List is DataView boundView
   && boundView.Table is DataTable dataTable)
  {
    int insert = 0;
    int update = 0;

    // 処理確認
    foreach (DataRow row in dataTable.Rows)
    {
      if (row.RowState == DataRowState.Added)
      {
        insert++;
      }
      else if (row.RowState == DataRowState.Modified)
      {
        update++;
      }
    }
    // 処理すべき行があるか確認
    if (lstDelete.Count == 0 && insert == 0 && update == 0)
    {
      MessageBox.Show("更新対象が存在しません", "ERROR",
                      MessageBoxButtons.OK, MessageBoxIcon.Error);
      return;
    }
    // データ更新処理
    using (var connection = new Microsoft.Data.SqlClient.SqlConnection(
                                GetConnectionString()))
    {
      connection.Open();
      var transaction = connection.BeginTransaction();

      try
      {
        // DELETE
        if (lstDelete.Count > 0)
        {
          var sql = "DELETE FROM Northwind.Shippers WHERE [ShipperID] = @Id";
          using (var command = new Microsoft.Data.SqlClient.SqlCommand(
                                   sql, connection, transaction))
          {
            foreach (var id in lstDelete)
            {
              command.Parameters.AddWithValue("@id", id);
              var affected = command.ExecuteNonQuery();
              if (affected == 0)
              {
                // TODO - DELETE されていない
              }
            }
          }
        }
        // INSERT
        if (insert > 0)
        {
          var sql =  "INSERT INTO Northwind.Shippers "
                   + "([ShipperID],[CompanyName],[Phone]) VALUES (@Id, @Name, @Phone)";
          using (var command = new Microsoft.Data.SqlClient.SqlCommand(
                                   sql, connection, transaction))
          {
            foreach (DataRow row in dataTable.Rows)
            {
              if (row.RowState == DataRowState.Added
               && row["ShipperID"] is int id)
              {
                var name = row["CompanyName"]?.ToString()?.Trim() ?? string.Empty;
                var phone = row["Phone"]?.ToString()?.Trim() ?? string.Empty;
                command.Parameters.AddWithValue("@id", id);
                command.Parameters.AddWithValue("@Name", name);
                command.Parameters.AddWithValue("@Phone", phone);
                var affected = command.ExecuteNonQuery();
                if (affected == 0)
                {
                  // TODO - INSERT されていない
                }
              }
            }
          }
        }
        // UPDATE
        if (update > 0)
        {
          var sql = "UPDATE Northwind.Shippers SET " 
                  + "[CompanyName] = @Name, [Phone] = @Phone WHERE [ShipperID] = @Id";
          using (var command = new Microsoft.Data.SqlClient.SqlCommand(
                                   sql, connection, transaction))
          {
            foreach (DataRow row in dataTable.Rows)
            {
              if (row.RowState == DataRowState.Modified
               && row["ShipperID"] is int id)
              {
                var name = row["CompanyName"]?.ToString()?.Trim() ?? string.Empty;
                var phone = row["Phone"]?.ToString()?.Trim() ?? string.Empty;
                command.Parameters.AddWithValue("@id", id);
                command.Parameters.AddWithValue("@Name", name);
                command.Parameters.AddWithValue("@Phone", phone);
                var affected = command.ExecuteNonQuery();
                if (affected == 0)
                {
                  // TODO - UPDATE されていない
                }
              }
            }
          }
        }
        // コミット
        transaction.Commit();
        bCommit = true;
      }
      catch (Exception)
      {
        // ロールバック
        transaction.Rollback();
        // TODO
      }
      finally
      {
        connection.Close();
      }
    }
  }
  if (bCommit)
  {
     MessageBox.Show("データ更新しました", "INFO",
                     MessageBoxButtons.OK, MessageBoxIcon.Information);
     // SQL Server からデータ再取得
     DataLoad();
  }
}
```


## おまけ

DataGridView 表示件数が多いときには後述のような UI があると便利です。

::: note
以降のコードは、本記事サンプルと同様に下記設定をした DataGridView を前提とします。
- SelectionMode = DataGridViewSelectionMode.FullRowSelect;  // 行選択モード
- MultiSelect = false;             // 複数選択無効

下記設定については、どちらでも対応可能なコードとします。
- AllowUserToAddRows = false;      // 末尾行での行追加（true/false)
:::

::: note
以降の処理は DataGridView としての操作なので、前述コードのように DataTable を参照する必要はありません。
:::

### 現在行/全体件数 表示

現在行/全体件数 表示用に Label lblStatusCount を用意して、DataGridView.SelectionChange イベントを用いて表示更新します。

```CSharp
// .NET Framework 時 object? の ? 不要
private void DataGridView_SelectionChanged(object? sender, EventArgs e)
{
  if (sender is DataGridView dgv && dgv.CurrentRow is DataGridViewRow row)
  {
    UpdateStatusCount(dgv, row.Index);
  }
}
// 現在行/全体件数 表示更新
private void UpdateStatusCount(DataGridView dgv, int rowIndex)
{
  int rowMax = GetRealRowCount(dgv);
  if (rowMax > 0 && rowIndex < rowMax)
  {
    lblStatusCount.Text = $"{rowIndex + 1}/{rowMax} 件";
  }
  else
  {
    lblStatusCount.Text = "--/-- 件";
  }
}
// 行追加用の行を除外した件数取得
private int GetRealRowCount(DataGridView dgv)
{
  // AllowUserToAddRows が true/false どちらにも対応
  int rowMax = dgv.Rows.Count - (dgv.AllowUserToAddRows ? 1 : 0);
  return rowMax;
}
```

::: note
DataGridView に DataTable をバインドした場合、先頭行追加時に SelectionChanged が発生するので、バインド後に UpdateStatusCount(dataGridView1, 0) を呼び出す必要があります。
:::


### 先頭行/末尾行に移動

先頭行、もしくは、末尾行を選択して、対象行を DataGridView に表示します。
（スクロールバーで表示範囲外となっているケースを考慮）

NavigateTopRow、NavigateBottomRow をボタン、メニューアイテムなどのクリックイベントで呼び出してください。

```CSharp
// 先頭行に移動
private void NavigateTopRow(DataGridView dgv)
{
  int rowMax = GetRealRowCount(dgv);
  if (rowMax > 0)
  {
    NavigateRowPosition(dgv, 0);
  }
}
// 末尾行に移動
private void NavigateBottomRow(DataGridView dgv)
{
  int rowMax = GetRealRowCount(dgv);
  if (rowMax > 0)
  {
    NavigateRowPosition(dgv, rowMax - 1);
  }
}
// 指定行を選択、選択行を表示
private void NavigateRowPosition(DataGridView dgv, int rowIndex)
{
  dgv.Rows[rowIndex].Cells[0].Selected = true;    // 選択行を更新
  dgv.FirstDisplayedScrollingRowIndex = rowIndex; // 対象行を表示
  dgv.Focus();

  // 現在行/全体件数 表示を実施している場合には、コメントを外す
  // UpdateStatusCount(dgv, rowIndex);
}
```

::: note
上記 NavigateRowPosition が呼ばれると、選択中セルが編集中の場合、編集内容を確定して、指定行の先頭列を選択します。
編集中の内容を破棄して指定行に移動させたい場合、以下のような対処があります。
- CellBeginEdit イベントで DataGridViewCell.Value を DataGridViewCell.Tag に保存
- NavigateRowPosition で DataGridView.CurrentCell.IsInEditMode の場合、Tag に保存していた編集前の値を Value に復元

行追加と同様に行編集もサブフォーム化すれば、このような対応は不要となります。
:::


### ソート処理時に選択行を追随

列ヘッダでソートした場合、選択されていた行ではなく、ソート後に同一位置の行が選択されます。
本記事サンプルは、ShipperID が Primary Key なので、DataGridView の CellMouseDown、ColumnHeaderMouseClick イベントを用いることで、選択行の追随が可能です。

```CSharp
private int CurrentRowKey = -1;
// .NET Framework 時 object? の ? 不要
private void DataGridView_CellMouseDown(object? sender, DataGridViewCellMouseEventArgs e)
{
  // 列ヘッダ (e.RowIndex：-1)  
  if (e.RowIndex < 0 && sender is DataGridView dgv
   && dgv.CurrentRow is DataGridViewRow row && !row.IsNewRow
   && row.Cells["ShipperID"]?.Value is int id)
  {
    CurrentRowKey = id;
    return;
  }
  CurrentRowKey = -1;
}
// .NET Framework 時 object? の ? 不要
private void DataGridView_ColumnHeaderMouseClick(object? sender,
                                                 DataGridViewCellMouseEventArgs e)
{
  if (sender is DataGridView dgv && CurrentRowKey >= 0)
  {
    foreach (DataGridViewRow row in dgv.Rows)
    {
      if (row.Cells["ShipperID"]?.Value is int id
       && id == CurrentRowKey)
      {
        NavigateRowPosition(dgv, row.Index);
        return;
      }
    }
  }
}
```