bqコマンドにワイルドカード機能を追加したbq-superを作ってみた

この記事は [マイナビ Advent Calendar 2021](https://qiita.com/advent-calendar/2021/mynavi) の2日目の記事です。

こんにちは！ただいま11月30日の21時です。(厳密にはこんばんは！です)
今年もアドベントカレンダーの時期が近づいてまいりました。
1年とは本当に短いものですね！

今年は昨年以上にプレーヤー的な仕事ができていませんが、そんな中でもギリギリなんとか自分で実装をしたものを恥ずかしながら少し披露というか晒して参りたいと思います。

# 何を作ったの？
bigqueryの便利ツールである**bqコマンド**ですが、テーブルのリスト(ls)やコピー(cp)、削除(rm)コマンドがあるものの、ワイルドカードや正規表現が指定できないため、複数テーブルを一括処理する際の表現力に乏しいです。
そこで、**ワイルドカードでテーブル名を指定してリスト・コピー・削除ができるようするbqコマンドの発展版ツール**を作りました。
言語はpython3.9です。

# なぜ作ったの？
私の所属する組織AIシステム部では、AWSとGCPを主に利用しています。
最近データ分析や機械学習に関連する箇所はGCPに寄せていく流れも有り、BigQueryをデータ処理の起点として利用したりしています。

BigQueryには色々な操作がCLIで実行可能なbqコマンドなるものが存在します。
便利なツールである一方細かい点で不満があったりします。

- 不満なこと
    - **テーブルのリスト(ls)やコピー(cp)、削除(rm)コマンドでテーブル名の指定にワイルドカードや正規表現が使えない**

ということで、ls/cp/rmコマンドについて、ワイルドカードを利用できるように機能拡張しようと思い、本ツールを開発しました。[^1]

[^1]: ワイルドカードや正規表現等でプロジェクト名やデータセット、テーブル名を指定してテーブルコピーできるような柔軟なツールはないと思っていますが、もしも存在をご存知でしたら教えて下さい！車輪の再開発はできればしたくないですので。

ちなみに`bq-super`というネーミングは今となっては少し恥ずかしい感じもしますが、Gitリポジトリに初めにネーミングしてしまったので仕方がないのです！！ :cry: 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196303/dc8f4436-e7fc-bbc5-ad06-81d01f6da15c.png)

他にも日々のオペレーション上、不便なことがあるのですが、そういったもの色々諸々をこのツールに追加実装するなどしていきたいと思っています。
最近マネジメント業やデータ分析業が多くてなかなかエンジニアリング業に手がつけられていない現実がありますが、やれたらいいなと。

# 利用イメージ
本コマンドツールの利用イメージについて、ご紹介します。

### ワイルドカード付きテーブルリスト表示
以下のようなテーブル構成の時
<pre>
project-name(プロジェクト名)
├── test_bq_super(データセット名)
    ├── test_aaa_20201119
    ├── test_aaa_20201120
    ├── test_bq_super__20201119
    └── test_bq_super__20201120
</pre>

#### テーブル名全体をワイルドカードにしてリスト表示
```bash
$ python bq_super.py ls project-name:test_bq_super.*
project-name:test_bq_super.test_aaa_20201119
project-name:test_bq_super.test_aaa_20201120
project-name:test_bq_super.test_bq_super_20201119
project-name:test_bq_super.test_bq_super_20201120
```

#### データセットの一部と、テーブル名の一部をワイルドカードで指定してリスト表示
```bash
$ python bq_super.py ls project-name:test_bq_su*.test_aaa_*
project-name:test_bq_super.test_aaa_20201119
project-name:test_bq_super.test_aaa_20201120
```

### ワイルドカード付きテーブルコピー
以下のようなテーブル構成の時
<pre>
project-name(プロジェクト名)
├── test_bq_super(データセット名)
    ├── test_aaa_20201119
    ├── test_aaa_20201120
    ├── test_bq_super__20201119
    └── test_bq_super__20201120
</pre>

#### データセットの一部と、テーブル名の一部をワイルドカードで指定してテーブルコピーする
```bash
$ python bq_super.py cp project-name:test_bq_su*.test_aaa_* project-name:test_bq_su*.test_bbb_*
「コピー元テーブル名」->「コピー先テーブル名」のリストを表示します。
project-name:test_bq_super.test_aaa_20201119 -> project-name:test_bq_super.test_bbb_20201119
project-name:test_bq_super.test_aaa_20201120 -> project-name:test_bq_super.test_bbb_20201120
件数=2
テーブルコピーを実施する場合は'y'を入力してEnter> y
コピー処理を行います。
project-name:test_bq_super.test_aaa_20201119 -> project-name:test_bq_super.test_bbb_20201119 正常終了
project-name:test_bq_super.test_aaa_20201120 -> project-name:test_bq_super.test_bbb_20201120 正常終了
```
コピー実施前にプロンプトを出し、「y」を入力することで、コピー処理が実施されます。
テーブルコピーがされた結果、以下のような構成なります。(test_bbb_*の2つがコピーされた)
<pre>
project-name(プロジェクト名)
├── test_bq_super(データセット名)
    ├── test_aaa_20201119
    ├── test_aaa_20201120
    ├── test_bbb_20201119
    ├── test_bbb_20201120
    ├── test_bq_super__20201119
    └── test_bq_super__20201120
</pre>

### ワイルドカード付きテーブル削除
以下のようなテーブル構成の時
<pre>
project-name(プロジェクト名)
├── test_bq_super(データセット名)
    ├── test_aaa_20201119
    ├── test_aaa_20201120
    ├── test_bbb_20201119
    ├── test_bbb_20201120
    ├── test_bq_super__20201119
    └── test_bq_super__20201120
</pre>

#### データセットの一部と、テーブル名の一部をワイルドカードで指定してテーブル削除する
```bash
$ python bq_super.py rm project-name:test_bq_su*.test_bbb_*
削除対象テーブルのリストを表示します。
project-name:test_bq_super.test_bbb_20201119
project-name:test_bq_super.test_bbb_20201120
件数=2
以上のテーブルの削除処理を実施する場合は'y'を入力してEnter> y
テーブル削除処理を行います。
削除処理 project-name:test_bq_super.test_bbb_20201119 正常終了
削除処理 project-name:test_bq_super.test_bbb_20201120 正常終了
```
テーブル削除実施前にプロンプトを出し、「y」を入力することで、テーブル削除処理が実施されます。
テーブル削除がされた結果、以下のような構成なります。(test_bbb_*の2つが削除された)
<pre>
project-name(プロジェクト名)
├── test_bq_super(データセット名)
    ├── test_aaa_20201119
    ├── test_aaa_20201120
    ├── test_bq_super__20201119
    └── test_bq_super__20201120
</pre>

# ソースのチラ見せ
このままではなんとなくQiitaっぽくないので、最後にざっとソースの紹介をします。恐れ多いので一部のみご紹介します！

## コマンドパース部分(mainの部分)
argparseを利用しました。bqコマンドといえば、サブコマンドの構成になるのでargparseでパースするのが自然かなということで
argparseを選択。ざっと以下のような感じです。

### bq_super.py
```python
#!/usr/bin/env python3.9

import argparse
import sys

from subcommand.subcommand_base import SubCommandBase
from subcommand.subcommand_cp import SubCommandCp
from subcommand.subcommand_ls import SubCommandLs
from subcommand.subcommand_rm import SubCommandRm


def main():
    """メイン処理。
    パラメータをパースして、サブコマンドに分解しサブコマンドに処理を委譲する。
    """
    sub_command = parse_args(sys.argv[1:])
    sub_command.subcommand_handler()


def parse_args(args: list[str]) -> SubCommandBase:
    """起動パラメータをパースし、パラメータに紐付くサブコマンドを生成する。
    サブコマンドには「ls」「cp」「rm」等がある。

    Args:
        args (list[str]):
            argv の2番目以降をここに指定する。(1番目は自身のパスが入っているので不要)
            通常はここに`sys.argv[1:]`を指定すればOK。

    Raises:
        argparse.ArgumentError: 起動パラメータの誤り

    Returns:
        SubCommandBase: [description]
    """
    parser = argparse.ArgumentParser(
        description="""
        '*'によるワイルドカード指定ができるcp, ls, rmなど、bqコマンドではできない少し高機能なことをするコマンド。
        """
    )
    subparser = parser.add_subparsers(
        dest='subcommand_name',
        description="ワイルドカード指定可能なBigQueryテーブルのls, cp, rmコマンドなど",
    )

    parser_ls = subparser.add_parser('ls', help='テーブルのリスト表示をするサブコマンド。詳細は次を参照 `ls -h`')
    parser_ls.add_argument('target', help="リスト表示したいテーブル('*'でワイルドカード指定可能)を指定する。形式は`project_id:dataset_id.table_id`")

    parser_cp = subparser.add_parser('cp', help='テーブルのコピーをするサブコマンド。詳細は次を参照`cp -h`')
    parser_cp.add_argument('src', help="テーブルコピー元(`*`でワイルドカード指定可能)を指定する。形式は`project_id:dataset_id.table_id`")
    parser_cp.add_argument('dest', help="テーブルコピー先(`*`または`{index}`でコピー元のワイルドカードに展開された文字列の埋込みが可能(indexは1始まりパディングなし数値で、srcに指定した*の順序番号を指定する。指定例：`{1}`等)")   # noqa: E501 改行すると余計見にくい
    parser_cp.add_argument('-o', '--overwrite', action='store_true', help="コピー先テーブルが存在する時、問合せ無しで上書きします。")
    parser_cp.add_argument('-d', '--dryrun', action='store_true', help="処理内容を表示するにとどめ、実際の変更処理は行いません。")
    parser_cp.add_argument('-f', '--force', action='store_true', help="一切のユーザ問合せを行わず、指定通りの処理を実行します。予期せぬエラーが起こっても全ての処理の完遂を試みます。")     # noqa: E501 改行すると余計見にくい

    parser_rm = subparser.add_parser('rm', help='テーブルの削除(drop table)をするサブコマンド。詳細は次を参照 `rm -h`')
    parser_rm.add_argument('target', help="削除したいテーブル('*'でワイルドカード指定可能)を指定する。形式は`project_id:dataset_id.table_id`")
    parser_rm.add_argument('-d', '--dryrun', action='store_true', help="処理内容を表示するにとどめ、実際の変更処理は行いません。")
    parser_rm.add_argument('-f', '--force', action='store_true', help="一切のユーザ問合せを行わず、指定通りの処理を実行します。予期せぬエラーが起こっても全ての処理の完遂を試みます。")     # noqa: E501 改行すると余計見にくい

    parsed_args = parser.parse_args(args)
    if parsed_args.subcommand_name == "ls":
        return SubCommandLs(
            subcommand_name=parsed_args.subcommand_name,
            target=parsed_args.target,
        )
    elif parsed_args.subcommand_name == "cp":
        return SubCommandCp(
            subcommand_name=parsed_args.subcommand_name,
            src=parsed_args.src,
            dest=parsed_args.dest,
            overwrite=parsed_args.overwrite,
            dryrun=parsed_args.dryrun,
            force=parsed_args.force,
        )
    elif parsed_args.subcommand_name == "rm":
        return SubCommandRm(
            subcommand_name=parsed_args.subcommand_name,
            target=parsed_args.target,
            dryrun=parsed_args.dryrun,
            force=parsed_args.force,
        )
    elif parsed_args.subcommand_name is None:
        print("利用方法は、-h または --help を指定すると表示されます。")
        exit(0)
    else:
        print(parsed_args.subcommand_name)
        raise argparse.ArgumentError(subparser, "未知のサブコマンド")


if __name__ == '__main__':
    main()
```

上記のコマンドパースで、ざっと以下のようなUsageが表示できることとなります。

```bash
$ python bq_super.py --help
usage: bq_super.py [-h] {ls,cp,rm} ...

'*'によるワイルドカード指定ができるcp, ls, rmなど、bqコマンドではできない少し高機能なことをするコマンド。

optional arguments:
  -h, --help  show this help message and exit

subcommands:
  ワイルドカード指定可能なBigQueryテーブルのls, cp, rmコマンドなど

  {ls,cp,rm}
    ls        テーブルのリスト表示をするサブコマンド。詳細は次を参照 `ls -h`
    cp        テーブルのコピーをするサブコマンド。詳細は次を参照`cp -h`
    rm        テーブルの削除(drop table)をするサブコマンド。詳細は次を参照 `rm -h`
```

## サブコマンド側
上記parse_args関数にて、サブコマンドの特定と、サブコマンドへのパラメータ指定をしてサブコマンドオブジェクト(`ls` or `cp` or `rm`)を生成しています。
そして、各種サブコマンドの抽象クラス`SubCommandBase`は以下の通り。単純ですが。

### subcommand_base.py
```python
from abc import ABC, abstractmethod


class SubCommandBase(ABC):
    @abstractmethod
    def subcommand_handler(self) -> None:
        raise NotImplementedError
```

### subcommand_ls.py
上記は抽象クラスですが、lsコマンドとしての具象クラスは以下のような感じです。

```python
from dataclasses import dataclass

from bq.table_search import TableSearchCriteria, search_tables_with_wildcard
from subcommand.subcommand_base import SubCommandBase


@dataclass(frozen=True)
class SubCommandLs(SubCommandBase):
    subcommand_name: str
    target: str

    def subcommand_handler(self):
        # 指定された条件でテーブルを検索する
        search_criteria = TableSearchCriteria(self.target)
        results = search_tables_with_wildcard(search_criteria)

        for result in results.search_results:
            print(result.table.full_table_id)
```

以上のような雰囲気で実装しています。
割とTypeHintはちゃんと書くように、チームメンバーにはお願いしていますし、私自身もできるだけ正確にTypeHintを書くようにしています。
あと、極力イミュータブルで済むものはイミュータブルになるように心がけています。
こんな感じで実装していますね。

# おわりに
いかがでしたでしょうか。
処理としては大したことはしていませんが、デイリーバッチがコケたときなどに、**「特定の日付がつけられた複数テーブルを別の日のテーブルとしてコピーしたい」**であるとか**「特定の名前のテーブルの全日程分のテーブルを別の名前としてコピーしたい」**というようなユースケースでたまに活躍するツールとなっています。
他にもbqコマンドではかゆいところに届かないケースが存在しているので、暇を見つけてこのツールに実装していきたいなと考えております。


以上です。