JSONから生成したPSCustomObjectへの代入でエラーになった話


# 何の話？

ConvertFrom-JsonでJSONからオブジェクトへ変換し、ある個所へ値を代入しようした際にエラーになってしまう件を調べ、なんとかした話。


# 環境

Windows10 1903
WindowsServer2016 1607
PowerShell ver：5

# エラーに遭遇したシチュエーション

環境の都合上、PowershellでElasticsearchへの問合せを投げている。わざわざkibanaで作りこんで集計するほどでもない…というか決まった形で決まったデータを欲しいだけなので、ワンライナーやps1スクリプトを書き散らしていることが多い。

そんな手元作業用のスクリプトで、実行後に入力受け付けてJSON作りたいことがある。例えば、いくつかの日付を選んで対応するデータ一覧を出力し確認したい、なんて場合。スクリプトの日付箇所をいちいち書き換えて実行するより入力を受け付けるほうがいくらか健全だろう。

文字列操作でJSON組み立てなんて宮沢静虎に頼まれてもやりたくないので、問合せに使うJSONをConvertFrom-Jsonで変換し、日付とIDを入力して置き換え、ConvertTo-Jsonで戻す、という処理をやる。

なおエラーハンドリングやバリデーションは一切省略。他人に使わせることはない書き散らしスクリプトだし、何かあっても自分で直せばいい範囲の作業なので。

```powershell:入力受けつけてクエリ用JSONを作成.ps1
# 最初に入っている値はダミー
$JsonTemplate = @'
{
    "size": 100,
    "query": {
        "bool": {
            "filter": [
                {
                    "range": {
                        "timestamp": {
                            "gte": "2000-00-00T00:00:00",
                            "lte": "2000-00-00T23:59:59"
                        }
                    }
                },
                {
                    "match": {
                        "id": "0000"
                    }
                }
            ]
        }
    }
}
'@

# オブジェクトに変換
$ObjFromJson = $JsonTemplate | ConvertFrom-Json

# ユーザーの項目入力
$Date_start = Read-Host "開始日 yyyy-HH-dd 00:00:00"
$Date_end = Read-Host "終了日 yyyy-HH-dd 23:59:59"
$MatchId = Read-Host "ID" 
$ObjFromJson.query.bool.filter.range.timestamp.gte = "${Date_start}T00:00:00"
$ObjFromJson.query.bool.filter.range.timestamp.lte = "${Date_end}T23:59:59"
$ObjFromJson.query.bool.filter.match.id = $MatchId

# クエリ用JSON生成
$JsonForQuery = $ObjFromJson | ConvertTo-Json -Depth 6

# この後$JsonForQueryをElasticsearchAPIに投げてどうたら

```

これを実行すると下のエラーになった。

```console
このオブジェクトにプロパティ 'id' が見つかりません。プロパティが存在し、設定可能であることを確認してください。
発生場所 D:\入力受けつけてクエリ用JSONを作成.ps1:33 文字:1
+ $ObjFromJson.query.bool.filter.match.id = $MatchId
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (:) []、RuntimeException
    + FullyQualifiedErrorId : PropertyAssignmentException
```

`$ObjFromJson.query.bool.filter.match.id`への代入箇所でエラーになっているようだ。


# 調査：型を調べたら

アクセスできないのか？と思い$ObjFromJson.query.bool.filter.match.idを表示させてみると、ちゃんとデフォルト中身の0000が表示される。存在しているしアクセスもできる。

```console
PS C:\Users\administrator> $ObjFromJson.query.bool.filter.match.id
0000
```

表示できるのに、エラーではそんなもの見つからないよと言ってきている。さてどうしてくれようか。

この後めちゃくちゃドハマリし、調査と検索を繰り返していろんな記事やドキュメントを読んで時間を費やした。それ自体は省略。
[のび太.pemの教訓](https://qiita.com/jabba/items/b0a0f8c310e164b4db2b)を元に、基本的なところを調べ直すことにした。変換したオブジェクトの各箇所で型を調べたところ、代入できる箇所とできない箇所では型の違いがあることがわかった。

```console
$ObjFromJson.query.bool.filter.range.gettype()
$ObjFromJson.query.bool.filter.range.timestamp.gettype()
$ObjFromJson.query.bool.filter.range.timestamp.gte.gettype()
$ObjFromJson.query.bool.filter.match.gettype()
$ObjFromJson.query.bool.filter.match.id.gettype()
```

```console
IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Object[]                                 System.Array
True     False    PSCustomObject                           System.Object
True     True     String                                   System.Object
True     True     Object[]                                 System.Array
True     True     String                                   System.Object
```

3つ目、代入できるgteのところは、ひとつ上のtimestampの箇所がPSCustomObject / System.Object。
5つ目、代入しようとするとエラーになるidのところは、ひとつ上のmatchの箇所がObject[] / System.Array。
前者は.gteへ代入できて、後者は.idへ代入できない。

# 解決１：暫定的な解決

Array。じゃあ添字か？てことで見当を付けてあれこれ試し、以下の書き方で代入できることを発見した。

```:console
$ObjFromJson.query.bool.filter.match[1].id = $MatchId

```

```console
Write-Host $ObjFromJson.query.bool.filter.match.id
# MatchIDが入っている

Write-Host $ObjFromJson.query.bool.filter.match[0].id
# Null

Write-Host $ObjFromJson.query.bool.filter.match[1].id
# MatchIDが入っている
# [2]以降は無い
```

確かにこれで動く。
`$ObjFromJson.query.bool.filter.range.timestamp`は`PSCustomObject / System.Object`なので.gteの書き方で下位へアクセスできて、`$ObjFromJson.query.bool.filter.match`は`Object[] / System.Array`なので.idの書き方では下位へアクセスできない…ということのようだ。

ObjectだArrayやらObject[]だPSCustomObjectやらお前何者だよなんでこんな型になってんだよとか、なんで[1]でだけ代入できるんだよとか、もろもろの疑問は全部飲み込んで、動いたから解決したことにしてとりあえず〆た。調べ疲れた。どうせ自分だけが使うものなんだからいいんだ。


# 解決２：ハッシュテーブルを使う

その後、また同スクリプトを使う機会があったので、現場へ戻ってきた。
考えてみればConvertFrom-JsonはPSCustomObjectを出力する仕様なのだし、おまかせしたからにはどんな仕様だろうが何をされてようが「おまかせしたんだろ？」ってわけで文句言う筋合いはないのかも。気にくわないなら自分で作れば？というもんだろう。

じゃあConvertFrom-Json以外に何かオブジェクト作る方法ないか、とあれこれ調べていくつか記事を見つけた。`JavaScriptSerializer.Deserialize`メソッドを使うといいらしい。

[PowerShell で JSON 変換をするときに発生するアレコレ](https://qiita.com/c-yan/items/7d0c916ed6f30b3775dd#a4-systemwebscriptserializationjavascriptserializer-%E3%82%92%E4%BD%BF%E3%81%86)
[JavaScriptSerializer クラス](https://docs.microsoft.com/ja-jp/dotnet/api/system.web.script.serialization.javascriptserializer?view=netframework-4.8)
[JavaScriptSerializer.Deserializeメソッド](https://docs.microsoft.com/ja-jp/dotnet/api/system.web.script.serialization.javascriptserializer.deserialize?view=netframework-4.8)



これを利用して最初のスクリプトを書き換えてみる。ConvertFrom-Jsonの行が変わるだけだが。

```powershell:入力受けつけてクエリ用JSONを作成・改.ps1
$JsonTemplate = @'
{
    "size": 100,
    "query": {
        "bool": {
            "filter": [
                {
                    "range": {
                        "timestamp": {
                            "gte": "2000-00-00T00:00:00",
                            "lte": "2000-00-00T23:59:59"
                        }
                    }
                },
                {
                    "match": {
                        "id": "0000"
                    }
                }
            ]
        }
    }
}
'@

# ハッシュテーブルに変換
$ObjFromJson =
(New-Object System.Web.Script.Serialization.JavaScriptSerializer).Deserialize(
  $JsonTemplate, [System.Collections.Hashtable]
)

# ユーザーの項目入力
$Date_start = Read-Host "開始日 yyyy-HH-dd 00:00:00"
$Date_end = Read-Host "終了日 yyyy-HH-dd 23:59:59"
$MatchId = Read-Host "ID" 
$ObjFromJson.query.bool.filter.range.timestamp.gte = "${Date_start}T00:00:00"
$ObjFromJson.query.bool.filter.range.timestamp.lte = "${Date_end}T23:59:59"
$ObjFromJson.query.bool.filter.match.id = $MatchId

# クエリ用JSON生成
$JsonForQuery = $ObjFromJson | ConvertTo-Json -Depth 6

# この後$JsonForQueryをElasticsearchAPIに投げてどうたら
```

これで問題なく動作する。エラーになっていた代入の個所は、配列添字無しの`$ObjFromJson.query.bool.filter.match.id = $MatchId`で動作する。

型も調べてみる。

```console
$ObjFromJson.query.bool.filter.range.gettype()
$ObjFromJson.query.bool.filter.range.timestamp.gettype()
$ObjFromJson.query.bool.filter.range.timestamp.gte.gettype()
$ObjFromJson.query.bool.filter.match.gettype()
$ObjFromJson.query.bool.filter.match.id.gettype()
```

```console
IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Dictionary`2                             System.Object
True     True     Dictionary`2                             System.Object
True     True     String                                   System.Object
True     True     Dictionary`2                             System.Object
True     True     String                                   System.Object
```

System.Objectで揃っていた。



# 教訓

エラーメッセージに注目しすぎて、型を調べて比較する、といった基本的なことを後回しにして時間を浪費した。
エラーへの対処は基本的なところから根気よく。銀の弾丸探しよりもたくさんの鉛弾。