🔥

VB.NETとSQLサーバを用いた開発(外部API利用編、後編)

に公開

はじめに

前回の続きです。
https://zenn.dev/nbs_tokyo/articles/061efc85b11fda

地名のComboBox作成

ComboBox作成のために、まずはそこに入れるための地名テーブルを作ります。

これが出来たら次は、ストアドを作っていきます。

まずはストアドを、こんな感じに作ってみました。

USE [DB]
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO

-- プロシージャ定義部
ALTER PROCEDURE [dbo].[国_抽出]

AS

-- 処理部
SELECT *

FROM [dbo]. [ComboBox]
WHERE Combo='国'

ORDER BY ID
USE [DB]
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO

-- プロシージャ定義部
ALTER PROCEDURE [dbo].[地名_抽出]
@国 nvarchar(50)
AS

-- 処理部
SELECT *

FROM [dbo]. [ComboBox]
WHERE Combo='地名' AND 所属=@国

ORDER BY ID

国、地名のテーブルを1まとめにしているので、今回はWhere句で使うConboBoxを指定しました。
これが終わったら、ComboBoxを作っていきます。
ここまで含めて、ほぼ実践編でやったことと同じです。

まずは、Form1のロード時に国のComboBoxが読み込まれるようにして

Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
    LoadCountry()
End Sub
 Private Sub LoadCountry()
     ' SQL Server 接続文字列
     Env.Load()
     Dim server As String = Env.GetString("SERVER")
     Dim database As String = Env.GetString("DATABASE")
     Dim connectionString As String = "Server=" + server + ";Database=" + database + ";Trusted_Connection=True;TrustServerCertificate=True;"

     Try
         Using conn As New SqlConnection(connectionString)
             Using cmd As New SqlCommand("国_抽出", conn)
                 cmd.CommandType = CommandType.StoredProcedure

                 conn.Open()

                 Using reader As SqlDataReader = cmd.ExecuteReader()
                     Dim prefList As New List(Of String)

                     While reader.Read()
                         prefList.Add(reader("名前").ToString())
                         realcountry = reader("別名").ToString()
                     End While

                     ' ComboBox にセット
                     ComboBox_国.DataSource = prefList
                 End Using
             End Using
         End Using

     Catch ex As Exception
         MessageBox.Show("エラー: " & ex.Message)
     End Try
 End Sub

次に、国が変わった時に地名のコンボボックスが決まる仕様を作ります。

 Private Sub ComboBox_国_SelectedIndexChanged(sender As Object, e As EventArgs) Handles ComboBox_国.SelectedIndexChanged
     ComboBox_地名.Enabled = True
     ComboBox_地名.Items.Clear()

     Dim selectedPref As String = ComboBox_国.SelectedItem.ToString()
     ' SQL Server 接続文字列
     Dim server As String = Env.GetString("SERVER")
     Dim database As String = Env.GetString("DATABASE")
     Dim connectionString As String = "Server=" + server + ";Database=" + database + ";Trusted_Connection=True;TrustServerCertificate=True;"

     Try
         Using conn As New SqlConnection(connectionString)
             ' ストアドプロシージャ名は市町村抽出用に用意しているもの
             Using cmd As New SqlCommand("地名_抽出", conn)
                 cmd.CommandType = CommandType.StoredProcedure
                 ' IDをパラメータとして渡す
                 cmd.Parameters.AddWithValue("@国", selectedPref)

                 conn.Open()

                 Using reader As SqlDataReader = cmd.ExecuteReader()
                     While reader.Read()
                         ComboBox_地名.Items.Add(reader("名前").ToString())
                         reallocation = reader("別名").ToString()
                     End While
                 End Using
             End Using
         End Using
     Catch ex As Exception
         MessageBox.Show("エラー: " & ex.Message)
     End Try
     ComboBox_地名.SelectedIndex = 0
 End Sub

どうせなので、サーバ名、データベース名も環境変数にしてみました。
やり方は前編でやった方法と同じです。

また、今回はComboBoxに記載された値ではなく、先程のテーブルにあった「別名」を、API側に送る値として使っていきたいと思います。
日本語名ではなく、アルファベットでAPIに情報を送る必要があるためです。
なので、名前抽出用のストアドを作ります。

USE [DB]
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO

-- プロシージャ定義部
CREATE PROCEDURE [dbo].[別名_抽出]
@名前 nvarchar(50)
AS

-- 処理部
SELECT 別名

FROM [dbo]. [ComboBox]
WHERE 名前=@名前

ORDER BY ID

これで、名前を渡すと、別名が返ってきます。
最後に、名前を渡したら、別名に書き換わります。

        Dim realcountry As String = ""
        Dim realcity As String = ""
        Dim connectionString As String = "Server=" + server + ";Database=" + database + ";Trusted_Connection=True;TrustServerCertificate=True;"

        Try
            Using conn As New SqlConnection(connectionString)
                Using cmd As New SqlCommand("別名_抽出", conn)
                    cmd.CommandType = CommandType.StoredProcedure
                    cmd.Parameters.AddWithValue("@名前", ComboBox_国.Text)

                    conn.Open()

                    Using reader As SqlDataReader = cmd.ExecuteReader()
                        While reader.Read()
                            realcountry = reader("別名").ToString()
                        End While
                    End Using
                End Using
            End Using
        Catch ex As Exception
            MessageBox.Show("エラー: " & ex.Message)
        End Try

これは国用ですが、地名用に同じようなやつをもう一個作りましょう。
realcountry,realcityは、それぞれ実際に送る用の国、地名の名称です。

Dim url As String = $"https://api.openweathermap.org/data/2.5/weather?q={Uri.EscapeDataString(realcity)},{Uri.EscapeDataString(realcountry)}&units=metric&appid={apiKey}"

MessageBox.Show("国:" + realcountry + vbCrLf + "地名:" + realcity+ vbCrLf + url)

これで、URLには英語名が送られます。
実際の挙動を見ていきます。
アメリカ、アラスカを選択すると、こんな感じに別名での表記でURLが書かれて


気温と天気が出ました!
こんな夏場に10℃台なので、アラスカで間違いないでしょう。

履歴テーブルの作成


履歴として最低限必要な情報は、地名、気温、天気、検索日時なので、その4列でテーブルを作りましょう。

一方、Form側はこんな感じで作りました。
SQLのテーブルは4列に対して、Form側のテーブルでは6列で作られているのは違和感がありますが、実はこれで正しいです。

ストアド経由でテーブルを作成する際、別のテーブル(ComboBox)から列を流用しているためです。

詳しく見ていきます。

USE [DB]
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO

-- プロシージャ定義部
ALTER PROCEDURE [dbo].[天気履歴_抽出]
AS

-- 処理部
BEGIN
    SET NOCOUNT ON;

    SELECT
        TH.[名前]           AS 名前,
        CB.[所属]           AS 国,
        TH.[気温]           AS 気温,
        TH.[天気]           AS 天気,
        TH.[検索日時]       AS 検索日時,
        CB.[ID]             AS ID
    FROM [dbo].[天気履歴] TH
    INNER JOIN [dbo].[ComboBox] CB
        ON TH.[名前] = CB.[名前]
    ORDER BY TH.[検索日時] desc
END


実行結果

名前、気温、天気、検索日時は天気履歴から、所属(国)、IDはComboBoxから列を射影していることがわかると思います。
これらを天気履歴を主にINNER JOINすることで、従となるComboBoxの情報が天気履歴側のテーブルの、情報と一致した列が付加されます。

ちなみに、THとかCBは、テーブルの略称みたいなものです。
ASは、ストアドでテーブルを作って渡す際に、その新設された列の名前を示します。
そんな感じで作ったテーブルをDataGridViewに入れます。

Private Sub データ表示()
    Dim server As String = Env.GetString("SERVER")
    Dim database As String = Env.GetString("DATABASE")
    Dim connectionString As String = "Server=" + server + ";Database=" + database + ";Trusted_Connection=True;TrustServerCertificate=True;"
    Try
        Using conn As New SqlConnection(connectionString)
            Using cmd As New SqlCommand("天気履歴_抽出", conn)
                cmd.CommandType = CommandType.StoredProcedure

                conn.Open()
                Using reader As SqlDataReader = cmd.ExecuteReader()
                    ' 既存の行をクリア
                    DataGridView_天気履歴.Rows.Clear()

                    ' データを1行ずつ追加
                    While reader.Read()
                        Dim rowIndex As Integer = DataGridView_天気履歴.Rows.Add()
                        ' reader の列順に従って DataGridView の列に代入
                        For i As Integer = 0 To reader.FieldCount - 1
                            ' DataGridView の列数に収まるようにチェック
                            If i < DataGridView_天気履歴.Columns.Count - 1 Then
                                DataGridView_天気履歴.Rows(rowIndex).Cells(i).Value = If(reader(i) Is DBNull.Value, "", reader(i))
                            Else
                                DataGridView_天気履歴.Rows(rowIndex).Cells(i).Value = "削除"
                            End If
                        Next
                    End While
                End Using
            End Using
        End Using

    Catch ex As Exception
        MessageBox.Show("エラー: " & ex.Message)
    End Try
End Sub

基本はこの前と同じなんですが、DataGridViewの最終列を削除ボタンにしたり(いったん削除の列のColumnTypeを「DataGridViewButtonColumn」にする、最終列の値を「削除」にする)してます。

検索履歴を逐次呼び出しているだけなので、とくにこれ以上の操作は不要です。

履歴追加に制限をかける

次に、不要な検索を行うことによるAPIのリクエスト制限を防ぐために、履歴追加に制限を加えていきます。
まずは、簡単にVB.NET側から作っていきます

Try
    Using conn As New SqlConnection(connectionString)
        Using cmd As New SqlCommand("既存検索_抽出", conn)
            cmd.CommandType = CommandType.StoredProcedure
            cmd.Parameters.AddWithValue("@名前", ComboBox_地名.Text)
            ' 戻り値パラメータ
            Dim returnParam As SqlParameter = cmd.Parameters.Add("ReturnValue", SqlDbType.Int)
            returnParam.Direction = ParameterDirection.ReturnValue

            conn.Open()
            cmd.ExecuteNonQuery()

            result = Convert.ToInt32(returnParam.Value)
            If result = -1 Then
                MessageBox.Show("1時間以内に同地域で検索した履歴があります。" + vbCrLf + "履歴を削除するか、同地域の最終検索から1時間以上開けてから再度検索してください。")
            Else

            End If
        End Using
    End Using
Catch ex As Exception
    MessageBox.Show("エラー: " & ex.Message)
End Try

If result = 1 Then
    (ここにその後の登録動作を書き込む)
Else
End If

要するに、ComboBoxの名前でストアドで検索を行い、result=1ならその後の動作(APIを叩き、データ追加)が許可され、result=-1ならMessageを出してスルーすると書いています。
そのストアドの動作がこちら

USE [DB]
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO

-- プロシージャ定義部
ALTER PROCEDURE [dbo].[既存検索_抽出]
    @名前 nvarchar(50)
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @検索日時 datetime;
    DECLARE @差分分 int;

    -- 最新の検索日時を取得
    SELECT TOP 1 
        @検索日時 = 検索日時
    FROM [dbo].[天気履歴]
    WHERE 名前 = @名前
    ORDER BY 検索日時 DESC;

    -- データがなければ Return=1
    IF @検索日時 IS NULL
    BEGIN
        RETURN 1;
    END

    SELECT *
        FROM [dbo].[天気履歴]
        WHERE 名前 = N'東京'
          AND 検索日時 >= DATEADD(HOUR, -1, GETDATE());

    -- 現在との差(分単位)
    SET @差分分 = DATEDIFF(MINUTE, @検索日時, GETDATE());

    -- 1時間以内なら -1、それ以外は 1
    IF @差分分 < 60
        RETURN -1;
    ELSE
        RETURN 1;
END

今回は、最終登録から1時間以内に同じ地名で再登録しようとした際に、RETURN=-1(失敗)、そうでなければ次に進めるように組んでみました。

    DECLARE @検索日時 datetime;
    DECLARE @差分分 int;

DECLAREで、ストアド内の変数を定義します。
あとはGPTに書かせたので注釈がいろいろ書いてありますので、だいたい理解できるかと思います。

するとやっぱり、登録時にMessageBoxが出てきて、APIへの接続およびテーブルへの追加がスルーされます。
これは実際、連続アクセスを防止するうえでよく使う技術となります。
APIは完全無料じゃないものが多いので、これがあるとかなり安心。

削除機能の実装

最後に、履歴の削除機能を実装していきます。
1時間のアクセス制限を無視したい…という意図もありますが、Form側でデータを削除するという技術は、SQLを使う上で絶対要求される所作です。

 Private Sub DataGridView_天気履歴_CellContentClick(sender As Object, e As DataGridViewCellEventArgs) Handles DataGridView_天気履歴.CellContentClick
     ' クリックされた列が最終列かチェック
     Dim dgv As DataGridView = CType(sender, DataGridView)
     If e.ColumnIndex = dgv.Columns.Count - 1 AndAlso e.RowIndex >= 0 Then
         ' クリックされた行から値を取得
         Dim 名前 As String = dgv.Rows(e.RowIndex).Cells(0).Value.ToString()   ' 2列目
         Dim 検索日時 As DateTime = dgv.Rows(e.RowIndex).Cells(4).Value.ToString()    ' 6列目

         ' ストアド呼び出し
         Dim server As String = Env.GetString("SERVER")
         Dim database As String = Env.GetString("DATABASE")
         Dim connectionString As String = "Server=" + server + ";Database=" + database + ";Trusted_Connection=True;TrustServerCertificate=True;"


         Try
             Using conn As New SqlConnection(connectionString)
                 Using cmd As New SqlCommand("天気履歴_削除", conn)
                     cmd.CommandType = CommandType.StoredProcedure

                     ' パラメータ
                     cmd.Parameters.AddWithValue("@名前", 名前)
                     cmd.Parameters.AddWithValue("@検索日時", 検索日時)

                     conn.Open()
                     Dim rowsDeleted As Integer = cmd.ExecuteNonQuery()
                     DataGridView_天気履歴.Rows.RemoveAt(DataGridView_天気履歴.CurrentCell.RowIndex)
                         データ表示()
                         MessageBox.Show("以下の情報は削除されました。" & vbCrLf & 名前 & ":" & 検索日時)
                 End Using
             End Using
         Catch ex As Exception
             MessageBox.Show("エラー: " & ex.Message)
         End Try
     End If
 End Sub


今回は、DataGridViewの最終行を削除ボタンにしていますので、当然最終行をクリックされた時のみ削除が動作するようにします。
また、同行の1行目の「名前」、5行目の「検索日時」をストアドへ送るので、その値も取得します。
そしたらあとは削除用のストアドを起動し、DataGridView側でも削除を押した行自体を消してやるだけです。

ストアド側も見ていきましょう。

USE [DB]
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO

-- プロシージャ定義部
ALTER PROCEDURE [dbo].[天気履歴_削除]
    @名前 nvarchar(50),
    @検索日時 datetime
AS
BEGIN
    SET NOCOUNT ON;

    DELETE FROM [dbo].[天気履歴]
    WHERE 名前 = @名前
      AND 検索日時 BETWEEN DATEADD(SECOND, -5, @検索日時)
                              AND DATEADD(SECOND, 5, @検索日時);
END

名前(nvarchar)と検索日時(datetime)をセットしたら
WHEREで名前、検索日時が一致する行を探し出し、DELETEで削除してやります。
ここでなんですが、検索日時はBETWEENを使って前後5秒ほど時間の余裕をもたせてあります。
SQLのテーブルの秒数と、Formに表示させているテーブルの秒数が、型の扱いの違いから完全一致しない可能性があるためです。

ということで削除。

念の為メッセージボックスに、削除した地域の名前と、検索した日時も表示しておきました。

まとめ

今回は一例として


を達成してみました。
実践編以上に、いろいろなことを学べたかなと思います。

まだまだできることはあると思うので、色々課題を見つけて自分でやってみましょう。

ネイバーズ東京

Discussion