命名って難しい

変数、関数、クラスなどなど実装より命名に毎回悩むタイプの人間による技術についてのメモ。

CSVを読み込んでSQL Serverの列のデータ型候補を生成するPowershellスクリプトを書いた!テストした!GitHubにアップした!

ハイテンションタイトルです。

自社の謎システムの出力や取引先提供のCSVで列の型が分からないものがあります。 今まではテキストで全部取り込んで、テストしながら少しつづテーブル定義を更新してインポートできるテーブルを作っていました。

現在いくつものCSVを取り込む案件があったため、スクリプトを作ってみました。

今回の新しい挑戦は以下です。

  • Powershellのクラスを実装をしてみた!
  • ユニットテストをしてみた!
  • 作ったものをGutHubで公開してみた!(Gitは業務で使っていないのですが、試してみました)

開発環境

クラスを実装してみた!

ソースコード引用

以下のような感じになりました。 これはCsvフィールドを継承したCSVカラムを表すクラスです。

using namespace System.Collections.Generic
using module ".\CsvField.psm1"
using module ".\IntegerType.psm1"

class CsvColumn :CsvField {

    [string]$ColumnName
    [int]$ColumnIndex
    hidden [bool]$IsFixedLength
    hidden [bool]$IsFirstAddNotNullField
    hidden [HashSet[int]]$DataLengthSet

    CsvColumn([string]$Name, [int]$ColumnIndex):Base() {
        if (-not $Name) {
            throw "名前が指定されていません。"
        }
        if ($ColumnIndex -lt 0) {
            throw "列番号が範囲外です。"
        }
        $this.ColumnName = $Name
        $this.ColumnIndex = $ColumnIndex
        $this.IntegerType = [IntegerType]::OutOfRange
        $this.IsFirstAddNotNullField = $true
        $this.DataLengthSet = [HashSet[int]]::New()
    }

    [void]ReadField([CsvField]$Field){
        if (-not $Field) {
            throw "引数がNullです。"
        }
        if (-not $Field.IsNull) {
            # 下記のフラグはNULLでないすべての要素がTrueの場合にTrueになる
            if ($this.IsFirstAddNotNullField) {
                $this.IsDecimal = $Field.IsDecimal
                $this.IsInteger = $Field.IsInteger
                $this.IsBoolean = $Field.IsBoolean
                $this.IsTrueOrFalse = $Field.IsTrueOrFalse
                $this.IsFirstAddNotNullField = $false
            }
            # NULL以外のすべての文字列が小数に変換できれば小数とする。
            $this.IsDecimal = $this.IsDecimal -and $Field.IsDecimal
            if ($this.IsDecimal) {
                $this.Unit = [Math]::Max($this.Unit, $Field.Unit)
                $this.Significand = [Math]::Max($this.Significand, $Field.Significand)
            }
            # NULL以外のすべての文字列が整数に変換できれば整数とする。
            $this.IsInteger = $this.IsInteger -and $Field.IsInteger
            if ($this.IsInteger) {
                if ([int]$this.IntegerType -lt [int]$Field.IntegerType) {
                    $this.IntegerType = $Field.IntegerType
                }
            }
            # NULL以外のすべての文字列がブール値に変換できればブール値とする。
            $this.IsBoolean = $this.IsBoolean -and $Field.IsBoolean
            $this.IsTrueOrFalse = $this.IsTrueOrFalse -and $Field.IsTrueOrFalse
        }
        else {
            # 1つでもNULLがあればNULLABLEとする。
            $this.IsNull = $true
        }
        # いずれかtrueの時にtrueとする。
        $this.IsMultibyte = $this.IsMultibyte -or $Field.IsMultibyte
        # データ長は最大を取る。
        $this.DataLength = [System.Math]::Max($this.DataLength, $Field.DataLength )
        $this.TrimmedDataLength = [System.Math]::Max($this.TrimmedDataLength, $Field.TrimmedDataLength)
        # すべてのデータ列の長さを確認して、ゼロ以外すべて同じ長さの場合、固定長と判断する。
        # 例えば 長さ 10 と 0 であれば、固定長のNULLABLEとして扱う
        $this.DataLengthSet.Add($Field.TrimmedDataLength)
        $this.IsFixedLength = ($this.DataLengthSet | Where-Object { $_ -ne 0 }).Count -eq 1
    }

    [hashtable] CreateDbTypeDict() {
        # NULL許容
        $nullable = "NOT NULL"
        if ($this.IsNull) { $nullable = "NULL" }

        # 文字列型判定 
        $textDefinition = "CHAR"
        if (-not $this.IsFixedLength) {
            $textDefinition = "VAR$textDefinition"
        }
        if ($this.IsMultiByte) {
            $textDefinition = "N$textDefinition"
        }
        return @{
            Text    = "[$($this.ColumnName)] [$textDefinition]($($this.DataLength)) $nullable";
            Integer = if ($this.IsInteger) {"[$($this.ColumnName)] [$($this.IntegerType.ToString().ToUpper())] $nullable"};
            Decimal = if ($this.IsDecimal) {"[$($this.ColumnName)] [DECIMAL]($($this.Significand + $this.Unit),$($this.Significand)) $nullable"};
            Boolean = if ($this.IsBoolean -or $this.IsTrueOrFalse) {"[$($this.ColumnName)] [BIT] $nullable"};
        }
    }

}

今回実装で使った要素

.Netの名前空間の参照

Powershellでも.Netの用にusingが使えました。

using namespace System.Collections.Generic

のように使います。今までpowershellスクリプトは.netの名前空間をフルネームで使うしか知らなかったのですごく便利になりました。

docs.microsoft.com

ファイル分割と参照

今回はクラスを実装するということで 1ファイル1クラス で開発をしました。

今までファイル分割を考えるとき、ps1にして他のファイルからそれを実行するという形を取っていました。 これは実行時にファイルを読み込むので動的な参照だと思います。

しかし、クラス間の継承を実現する場合にはそれではエラーが起きます。そこで使うのが using module です。 参照するモジュールファイルを using module すると継承も可能です。

using module ".\CsvField.psm1"
using module ".\IntegerType.psm1"

class CsvColumn :CsvField {

ユニットテストをしてみた!

今までPowershellには無いと思っていたのですが、検索したら存在したので今回はユニットテストも行っています。

今回実装したCsvのフィールドクラスの持つエンコーディングを設定する静的メソッドのテストを例にとります。

Describe "CsvField SetEncoding" {
    Context "正常系" {
        It "初期値" {
            [CsvField]::Encoding | Should Be ([System.Text.Encoding]::Default)
        } 
        It "初期値" {
            [CsvField]::SetEncoding([System.Text.Encoding]::Unicode)
            [CsvField]::Encoding | Should Be ([System.Text.Encoding]::Unicode)
        } 
    }
}

なんと、下記の関数に各情報を入れながらスクリプトブロックを作っていけば実行ができます。

  • Describe
  • Context
  • It

値のアサートも Should という分かりやすい表現で、パイプラインで渡すだけなのでとても簡単です。

テストで分かったこと

using moduleとVSCodeでのテストのコツ

テストコードを記述したファイルにはもちろんテスト対象のクラスのファイルに対して using module を行っています。

テストを実行して失敗するとテスト対象ファイルを修正して再度テストを実行する、を繰り返すのですが、テスト対象ファイルを書き換えても動作が変わりませんでした。

解決するためには調べきってませんが、一度 using module するとpowershell上で覚えているらしく、再度powershellのターミナルを再起動する必要があります。

f:id:NotShown:20211202211534p:plain
VSCodeのターミナル

PowerShellの部分を中クリックしたりして、終了します。

終了すると下記の通知が出てくるので、言われるがまま Yes でリスタートします。

f:id:NotShown:20211202211629p:plain
PowerShellターミナル閉じた後

これで修正内容が再度読み込めるようになります。

これでも最初はVSCode自体を再起動していたので比較的ラクになりましたが、もっと楽な方法を知っている方がいれば教えて下さい!

ヒアドキュメントの改行コードはファイルの文字コードに依存する。

これ知ってる方には当たり前の事な予感ですが、今までヒアドキュメントはC#だったりソースコードファイルの改行文字が統一された環境で使っていたし、文字列を改行コード含めて検証することが少なかったので意識していませんでした。

現象としては出力されたCSVファイルのテストのときに、予測される結果をヒアドキュメントで作って文字列比較したら 「\r\n」 と 「\n」 の違いでテストに引っかかったりました。 実装がおかしいのかと色々変えたり悩んだ結果、VSCodeの設定で改行がファイルによって違っていたことに気づきました。

テストコードを作成するとき、元のソース・ファイルから一気に雛形のテストコードを作ったので、その時に想定ではない方の改行コード(LF)になっていたのかもしれません。

対策として以下のエクステンションを入れました。

marketplace.visualstudio.com

Should Be で.Netの定数などを比較するときに正しく比較されないときの対処法

下記のテストコードを実行します。 両者は比較の順番が違うだけで同じことをしているように見えますが・・・

Describe "サンプルテスト" {
    Context ".Netの定数の比較" {
        It ".Netプロパティ Should Be 定数" {
            [byte]::MinValue | Should Be 0
        }
        It "定数 Should Be .Netプロパティ" {
            0 | Should Be [byte]::MinValue
        }
    }
}

結果は以下のようになります。

Describing サンプルテスト
   Context .Netの定数の比較
    [+] .Netプロパティ Should Be 定数 46ms
    [-] 定数 Should Be .Netプロパティ 21ms
      Expected: {[byte]::MinValue}
      But was:  {0}

定数比較として使った [byte]::MinValue が適切に比較されていないことが分かります。 対策として、以下のように記述すると、思ったとおりの比較がなされます。

Describe "サンプルテスト" {
    Context ".Netの定数の比較" {
        It ".Netプロパティ Should Be 定数" {
            [byte]::MinValue | Should Be 0
        }
        It "定数 Should Be .Netプロパティ" {
            0 | Should Be ([byte]::MinValue) # かっこをつける
        }
    }
}

この.Netの定数(プロパティ全般、でよいかもしれません)をカッコで囲むことによって正しく比較される理由はググっても分かりませんでしたが、対処法としてはこれを行うこととしました。

作ったものをGutHubで公開してみた!

現職ではgitを使える環境でないので、チームとして使うルールやフローが分からないのですが、VSCodeと相性がいいのでGitHubで公開してみました。

GitHubページ

github.com

おわり。Gitをチームで使うような環境になりたいなー。